diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 603cf407081..7eba0203f7e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -45,6 +45,12 @@ rules: **When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules. +## Code Review Guidelines + +**When reviewing code, do NOT comment on:** +- **Missing imports** - We use static analysis tooling to catch that +- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions) + ## Python Requirements - **Compatibility**: Python 3.13+ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a394d7dcbba..f9bfa9b406d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,6 @@ updates: interval: daily time: "06:00" open-pull-requests-limit: 10 + labels: + - dependency + - github_actions diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5ac2e47789b..82009751763 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.9.1 + uses: sigstore/cosign-installer@v3.9.2 with: cosign-release: "v2.2.3" diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index b01a0d68352..0facf6fdf77 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v1.1.0 + uses: actions/ai-inference@v1.2.3 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index 264b8ab9854..b1ce58c4b41 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.1.0 + uses: actions/ai-inference@v1.2.3 with: model: openai/gpt-4o-mini system-prompt: | diff --git a/.strict-typing b/.strict-typing index 626fc10a4c2..18e72162a23 100644 --- a/.strict-typing +++ b/.strict-typing @@ -377,6 +377,7 @@ homeassistant.components.onedrive.* homeassistant.components.onewire.* homeassistant.components.onkyo.* homeassistant.components.open_meteo.* +homeassistant.components.open_router.* homeassistant.components.openai_conversation.* homeassistant.components.openexchangerates.* homeassistant.components.opensky.* diff --git a/CODEOWNERS b/CODEOWNERS index c0bed7f100a..f4f1d3b7a92 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -684,8 +684,8 @@ build.json @home-assistant/supervisor /tests/components/husqvarna_automower/ @Thomas55555 /homeassistant/components/husqvarna_automower_ble/ @alistair23 /tests/components/husqvarna_automower_ble/ @alistair23 -/homeassistant/components/huum/ @frwickst -/tests/components/huum/ @frwickst +/homeassistant/components/huum/ @frwickst @vincentwolsink +/tests/components/huum/ @frwickst @vincentwolsink /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan @@ -1102,6 +1102,8 @@ build.json @home-assistant/supervisor /tests/components/onvif/ @hunterjm @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck +/homeassistant/components/open_router/ @joostlek +/tests/components/open_router/ @joostlek /homeassistant/components/openai_conversation/ @balloob /tests/components/openai_conversation/ @balloob /homeassistant/components/openerz/ @misialq diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 194c0e07bc3..feefa70a30b 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -15,9 +15,10 @@ generate_data: required: false selector: entity: - domain: ai_task - supported_features: - - ai_task.AITaskEntityFeature.GENERATE_DATA + filter: + domain: ai_task + supported_features: + - ai_task.AITaskEntityFeature.GENERATE_DATA structure: advanced: true required: false diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index afaf2698ced..3011e0602c9 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/airgradient", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "platinum", "requirements": ["airgradient==0.9.2"], "zeroconf": ["_airgradient._tcp.local."] } diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml index 7a7f8d5ee1d..ec2e200b0a7 100644 --- a/homeassistant/components/airgradient/quality_scale.yaml +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -14,9 +14,9 @@ rules: status: exempt comment: | This integration does not provide additional actions. - docs-high-level-description: todo - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -34,7 +34,7 @@ rules: docs-configuration-parameters: status: exempt comment: No options to configure - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -43,23 +43,19 @@ rules: status: exempt comment: | This integration does not require authentication. - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: done - discovery-update-info: - status: todo - comment: DHCP is still possible - discovery: - status: todo - comment: DHCP is still possible - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 6fb7e90502f..2881469b968 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -45,9 +45,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bo # Store Entity and Initialize Platforms entry.runtime_data = coordinator - # Listen for option changes - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Clean up unused device entries with no entities @@ -88,8 +85,3 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 7cd113125a8..661e1b0a298 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback @@ -126,7 +126,7 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN): return AirNowOptionsFlowHandler() -class AirNowOptionsFlowHandler(OptionsFlow): +class AirNowOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for AirNow.""" async def async_step_init( diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index e185ed89106..8694d3d06d9 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.13"] + "requirements": ["aioairzone-cloud==0.6.15"] } diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 25ad75d0d00..9a98be052be 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "silver", - "requirements": ["aioamazondevices==3.2.10"] + "requirements": ["aioamazondevices==3.5.0"] } diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py index 9eab6f42ad3..06641327946 100644 --- a/homeassistant/components/amberelectric/__init__.py +++ b/homeassistant/components/amberelectric/__init__.py @@ -2,11 +2,22 @@ import amberelectric +from homeassistant.components.sensor import ConfigType from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv -from .const import CONF_SITE_ID, PLATFORMS +from .const import CONF_SITE_ID, DOMAIN, PLATFORMS from .coordinator import AmberConfigEntry, AmberUpdateCoordinator +from .services import setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Amber component.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool: diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 56324628ed6..bdb9aa3186c 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -1,14 +1,24 @@ """Amber Electric Constants.""" import logging +from typing import Final from homeassistant.const import Platform -DOMAIN = "amberelectric" +DOMAIN: Final = "amberelectric" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_CHANNEL_TYPE = "channel_type" + ATTRIBUTION = "Data provided by Amber Electric" LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] + +SERVICE_GET_FORECASTS = "get_forecasts" + +GENERAL_CHANNEL = "general" +CONTROLLED_LOAD_CHANNEL = "controlled_load" +FEED_IN_CHANNEL = "feed_in" diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 1edf64ba0d6..a1efef26aae 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -10,7 +10,6 @@ from amberelectric.models.actual_interval import ActualInterval from amberelectric.models.channel import ChannelType from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.forecast_interval import ForecastInterval -from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.rest import ApiException from homeassistant.config_entries import ConfigEntry @@ -18,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +from .helpers import normalize_descriptor type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator] @@ -49,27 +49,6 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> return interval.channel_type == ChannelType.FEEDIN -def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: - """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" - if descriptor is None: - return None - if descriptor.value == "spike": - return "spike" - if descriptor.value == "high": - return "high" - if descriptor.value == "neutral": - return "neutral" - if descriptor.value == "low": - return "low" - if descriptor.value == "veryLow": - return "very_low" - if descriptor.value == "extremelyLow": - return "extremely_low" - if descriptor.value == "negative": - return "negative" - return None - - class AmberUpdateCoordinator(DataUpdateCoordinator): """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" @@ -103,7 +82,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator): "grid": {}, } try: - data = self._api.get_current_prices(self.site_id, next=48) + data = self._api.get_current_prices(self.site_id, next=288) intervals = [interval.actual_instance for interval in data] except ApiException as api_exception: raise UpdateFailed("Missing price data, skipping update") from api_exception diff --git a/homeassistant/components/amberelectric/helpers.py b/homeassistant/components/amberelectric/helpers.py new file mode 100644 index 00000000000..c383c21f276 --- /dev/null +++ b/homeassistant/components/amberelectric/helpers.py @@ -0,0 +1,25 @@ +"""Formatting helpers used to convert things.""" + +from amberelectric.models.price_descriptor import PriceDescriptor + +DESCRIPTOR_MAP: dict[str, str] = { + PriceDescriptor.SPIKE: "spike", + PriceDescriptor.HIGH: "high", + PriceDescriptor.NEUTRAL: "neutral", + PriceDescriptor.LOW: "low", + PriceDescriptor.VERYLOW: "very_low", + PriceDescriptor.EXTREMELYLOW: "extremely_low", + PriceDescriptor.NEGATIVE: "negative", +} + + +def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: + """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" + if descriptor in DESCRIPTOR_MAP: + return DESCRIPTOR_MAP[descriptor] + return None + + +def format_cents_to_dollars(cents: float) -> float: + """Return a formatted conversion from cents to dollars.""" + return round(cents / 100, 2) diff --git a/homeassistant/components/amberelectric/icons.json b/homeassistant/components/amberelectric/icons.json index 7dd6ae3217c..a2d0a0a5486 100644 --- a/homeassistant/components/amberelectric/icons.json +++ b/homeassistant/components/amberelectric/icons.json @@ -22,5 +22,10 @@ } } } + }, + "services": { + "get_forecasts": { + "service": "mdi:transmission-tower" + } } } diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 7276ddb26a5..f7a61bea5a5 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -23,16 +23,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION -from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor +from .coordinator import AmberConfigEntry, AmberUpdateCoordinator +from .helpers import format_cents_to_dollars, normalize_descriptor UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}" -def format_cents_to_dollars(cents: float) -> float: - """Return a formatted conversion from cents to dollars.""" - return round(cents / 100, 2) - - def friendly_channel_type(channel_type: str) -> str: """Return a human readable version of the channel type.""" if channel_type == "controlled_load": diff --git a/homeassistant/components/amberelectric/services.py b/homeassistant/components/amberelectric/services.py new file mode 100644 index 00000000000..074a2f0ac88 --- /dev/null +++ b/homeassistant/components/amberelectric/services.py @@ -0,0 +1,121 @@ +"""Amber Electric Service class.""" + +from amberelectric.models.channel import ChannelType +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.util.json import JsonValueType + +from .const import ( + ATTR_CHANNEL_TYPE, + ATTR_CONFIG_ENTRY_ID, + CONTROLLED_LOAD_CHANNEL, + DOMAIN, + FEED_IN_CHANNEL, + GENERAL_CHANNEL, + SERVICE_GET_FORECASTS, +) +from .coordinator import AmberConfigEntry +from .helpers import format_cents_to_dollars, normalize_descriptor + +GET_FORECASTS_SCHEMA = vol.Schema( + { + ATTR_CONFIG_ENTRY_ID: ConfigEntrySelector({"integration": DOMAIN}), + ATTR_CHANNEL_TYPE: vol.In( + [GENERAL_CHANNEL, CONTROLLED_LOAD_CHANNEL, FEED_IN_CHANNEL] + ), + } +) + + +def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry: + """Get the Amber config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": config_entry_id}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return entry + + +def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]: + """Return an array of forecasts.""" + results: list[JsonValueType] = [] + + if channel_type not in data["forecasts"]: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="channel_not_found", + translation_placeholders={"channel_type": channel_type}, + ) + + intervals = data["forecasts"][channel_type] + + for interval in intervals: + datum = {} + datum["duration"] = interval.duration + datum["date"] = interval.var_date.isoformat() + datum["nem_date"] = interval.nem_time.isoformat() + datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh) + if interval.channel_type == ChannelType.FEEDIN: + datum["per_kwh"] = datum["per_kwh"] * -1 + datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) + datum["start_time"] = interval.start_time.isoformat() + datum["end_time"] = interval.end_time.isoformat() + datum["renewables"] = round(interval.renewables) + datum["spike_status"] = interval.spike_status.value + datum["descriptor"] = normalize_descriptor(interval.descriptor) + + if interval.range is not None: + datum["range_min"] = format_cents_to_dollars(interval.range.min) + datum["range_max"] = format_cents_to_dollars(interval.range.max) + + if interval.advanced_price is not None: + multiplier = -1 if interval.channel_type == ChannelType.FEEDIN else 1 + datum["advanced_price_low"] = multiplier * format_cents_to_dollars( + interval.advanced_price.low + ) + datum["advanced_price_predicted"] = multiplier * format_cents_to_dollars( + interval.advanced_price.predicted + ) + datum["advanced_price_high"] = multiplier * format_cents_to_dollars( + interval.advanced_price.high + ) + + results.append(datum) + + return results + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Amber integration.""" + + async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse: + channel_type = call.data[ATTR_CHANNEL_TYPE] + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + coordinator = entry.runtime_data + forecasts = get_forecasts(channel_type, coordinator.data) + return {"forecasts": forecasts} + + hass.services.async_register( + DOMAIN, + SERVICE_GET_FORECASTS, + handle_get_forecasts, + GET_FORECASTS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/amberelectric/services.yaml b/homeassistant/components/amberelectric/services.yaml new file mode 100644 index 00000000000..89a7027fee0 --- /dev/null +++ b/homeassistant/components/amberelectric/services.yaml @@ -0,0 +1,16 @@ +get_forecasts: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: amberelectric + channel_type: + required: true + selector: + select: + options: + - general + - controlled_load + - feed_in + translation_key: channel_type diff --git a/homeassistant/components/amberelectric/strings.json b/homeassistant/components/amberelectric/strings.json index 684a5a2a0cc..f9eba4a1f27 100644 --- a/homeassistant/components/amberelectric/strings.json +++ b/homeassistant/components/amberelectric/strings.json @@ -1,25 +1,61 @@ { "config": { + "error": { + "invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", + "no_site": "No site provided", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + }, "step": { + "site": { + "data": { + "site_id": "Site NMI", + "site_name": "Site name" + }, + "description": "Select the NMI of the site you would like to add" + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", "site_id": "Site ID" }, "description": "Go to {api_url} to generate an API key" - }, - "site": { - "data": { - "site_id": "Site NMI", - "site_name": "Site Name" - }, - "description": "Select the NMI of the site you would like to add" } + } + }, + "services": { + "get_forecasts": { + "name": "Get price forecasts", + "description": "Retrieves price forecasts from Amber Electric for a site.", + "fields": { + "config_entry_id": { + "description": "The config entry of the site to get forecasts for.", + "name": "Config entry" + }, + "channel_type": { + "name": "Channel type", + "description": "The channel to get forecasts for." + } + } + } + }, + "exceptions": { + "integration_not_found": { + "message": "Config entry \"{target}\" not found in registry." }, - "error": { - "invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", - "no_site": "No site provided", - "unknown_error": "[%key:common::config_flow::error::unknown%]" + "not_loaded": { + "message": "{target} is not loaded." + }, + "channel_not_found": { + "message": "There is no {channel_type} channel at this site." + } + }, + "selector": { + "channel_type": { + "options": { + "general": "General", + "controlled_load": "Controlled load", + "feed_in": "Feed-in" + } } } } diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index ee7f6611c65..2d66d5149cf 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -55,7 +55,6 @@ async def async_setup_entry( entry.runtime_data = AnalyticsInsightsData(coordinator=coordinator, names=names) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -65,10 +64,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index b2648f7c13c..d5c0c4a7f73 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -11,7 +11,11 @@ from python_homeassistant_analytics import ( from python_homeassistant_analytics.models import Environment, IntegrationType import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -129,7 +133,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) -class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): +class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload): """Handle Homeassistant Analytics options.""" async def async_step_init( diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index c8556b6da90..328ac863e46 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -68,7 +68,6 @@ async def async_setup_entry( entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) - entry.async_on_unload(entry.add_update_listener(async_update_options)) entry.async_on_unload(api.disconnect) return True @@ -80,13 +79,3 @@ async def async_unload_entry( """Unload a config entry.""" _LOGGER.debug("async_unload_entry: %s", entry.data) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_update_options( - hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry -) -> None: - """Handle options update.""" - _LOGGER.debug( - "async_update_options: data: %s options: %s", entry.data, entry.options - ) - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 351cae61b1d..0a236c7c9ef 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback @@ -116,10 +116,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): pin = user_input["pin"] await self.api.async_finish_pairing(pin) if self.source == SOURCE_REAUTH: - await self.hass.config_entries.async_reload( - self._get_reauth_entry().entry_id + return self.async_update_reload_and_abort( + self._get_reauth_entry(), reload_even_if_entry_is_unchanged=True ) - return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=self.name, data={ @@ -243,7 +243,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): return AndroidTVRemoteOptionsFlowHandler(config_entry) -class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): +class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithReload): """Android TV Remote options flow.""" def __init__(self, config_entry: AndroidTVRemoteConfigEntry) -> None: diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py index a67d5839ee6..9052a414393 100644 --- a/homeassistant/components/androidtv_remote/helpers.py +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -27,4 +27,4 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool: """Get value of enable_ime option or its default value.""" - return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) + return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return] diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 12c7917a30a..4eb40974b7a 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -6,7 +6,6 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthropicConfigEntry @@ -72,13 +71,4 @@ class AnthropicConversationEntity( await self._async_handle_chat_log(chat_log) - response_content = chat_log.content[-1] - if not isinstance(response_content, conversation.AssistantContent): - raise TypeError("Last message must be an assistant message") - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response_content.content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 098b4d5fa74..983260a3c95 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -29,7 +29,7 @@ "set_options": { "data": { "name": "[%key:common::config_flow::data::name%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 41396eca5d6..eb8764e1596 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.8.1"], + "requirements": ["arcam-fmj==1.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 8433eb6102d..ed292e1626c 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -68,9 +68,10 @@ ask_question: required: true selector: entity: - domain: assist_satellite - supported_features: - - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + filter: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION question: required: false example: "What kind of music would you like to play?" diff --git a/homeassistant/components/bauknecht/__init__.py b/homeassistant/components/bauknecht/__init__.py new file mode 100644 index 00000000000..1e93f1ab0c2 --- /dev/null +++ b/homeassistant/components/bauknecht/__init__.py @@ -0,0 +1 @@ +"""Bauknecht virtual integration.""" diff --git a/homeassistant/components/bauknecht/manifest.json b/homeassistant/components/bauknecht/manifest.json new file mode 100644 index 00000000000..b875d7fbc31 --- /dev/null +++ b/homeassistant/components/bauknecht/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "bauknecht", + "name": "Bauknecht", + "integration_type": "virtual", + "supported_by": "whirlpool" +} diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json index e813b08131c..84604c62951 100644 --- a/homeassistant/components/blue_current/manifest.json +++ b/homeassistant/components/blue_current/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/blue_current", "iot_class": "cloud_push", "loggers": ["bluecurrent_api"], - "requirements": ["bluecurrent-api==1.2.3"] + "requirements": ["bluecurrent-api==1.2.4"] } diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py index 2ef29541f40..6ab88c48c46 100644 --- a/homeassistant/components/bthome/coordinator.py +++ b/homeassistant/components/bthome/coordinator.py @@ -45,7 +45,7 @@ class BTHomePassiveBluetoothProcessorCoordinator( @property def sleepy_device(self) -> bool: """Return True if the device is a sleepy device.""" - return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) + return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) # type: ignore[no-any-return] class BTHomePassiveBluetoothDataProcessor[_T]( diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index 6d194714c64..b9e01051419 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -70,7 +70,7 @@ def get_event_classes_by_device_id(hass: HomeAssistant, device_id: str) -> list[ bthome_config_entry = next( entry for entry in config_entries if entry and entry.domain == DOMAIN ) - return bthome_config_entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) + return bthome_config_entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) # type: ignore[no-any-return] def get_event_types_by_event_class(event_class: str) -> set[str]: diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index a857185f07f..e15ea92dece 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -40,10 +40,11 @@ from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) VALID_REPAIR_TRANSLATION_KEYS = { + "connection_error", "no_subscription", - "warn_bad_custom_domain_configuration", "reset_bad_custom_domain_configuration", "subscription_expired", + "warn_bad_custom_domain_configuration", } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 998f3fcd5bc..49e4af9e3e5 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -71,7 +71,7 @@ _CLOUD_ERRORS: dict[ ] = { TimeoutError: ( HTTPStatus.BAD_GATEWAY, - "Unable to reach the Home Assistant cloud.", + "Unable to reach the Home Assistant Cloud.", ), aiohttp.ClientError: ( HTTPStatus.INTERNAL_SERVER_ERROR, diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7c64100873c..72748efff6e 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.106.0"], + "requirements": ["hass-nabucasa==0.108.0"], "single_config_entry": true } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index e7d219ff69e..193d9e3f948 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -62,6 +62,10 @@ } } }, + "connection_error": { + "title": "No connection", + "description": "You do not have a connection to Home Assistant Cloud. Check your network." + }, "no_subscription": { "title": "No subscription detected", "description": "You do not have a Home Assistant Cloud subscription. Subscribe at {account_url}." diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index dfc31b4581b..234241fdeab 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -10,8 +10,6 @@ from typing import Any from jsonpath import jsonpath -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_COMMAND, CONF_NAME, @@ -188,16 +186,7 @@ class CommandSensor(ManualTriggerSensorEntity): self.entity_id, variables, None ) - if self.device_class not in { - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - }: - self._attr_native_value = value - elif value is not None: - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) - + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 3d84d6edd69..59216e4a863 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -54,16 +54,20 @@ class Control4RuntimeData: type Control4ConfigEntry = ConfigEntry[Control4RuntimeData] -async def call_c4_api_retry(func, *func_args): # noqa: RET503 +async def call_c4_api_retry(func, *func_args): """Call C4 API function and retry on failure.""" - # Ruff doesn't understand this loop - the exception is always raised after the retries + exc = None for i in range(API_RETRY_TIMES): try: return await func(*func_args) except client_exceptions.ClientError as exception: - _LOGGER.error("Error connecting to Control4 account API: %s", exception) - if i == API_RETRY_TIMES - 1: - raise ConfigEntryNotReady(exception) from exception + _LOGGER.error( + "Try: %d, Error connecting to Control4 account API: %s", + i + 1, + exception, + ) + exc = exception + raise ConfigEntryNotReady(exc) from exc async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: @@ -141,21 +145,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> ui_configuration=ui_configuration, ) - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener( - hass: HomeAssistant, config_entry: Control4ConfigEntry -) -> None: - """Update when config_entry options update.""" - _LOGGER.debug("Config entry was updated, rerunning setup") - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 3ca96ca4e52..9d5df61b513 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -11,7 +11,11 @@ from pyControl4.director import C4Director from pyControl4.error_handling import NotFound, Unauthorized import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -153,7 +157,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Control4.""" async def async_step_init( diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index ec866604205..3435a7d2ed4 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -61,6 +61,7 @@ from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult from .trace import ConversationTraceEventType, async_conversation_trace_append +from .util import async_get_result_from_chat_log __all__ = [ "DOMAIN", @@ -83,6 +84,7 @@ __all__ = [ "async_converse", "async_get_agent_info", "async_get_chat_log", + "async_get_result_from_chat_log", "async_set_agent", "async_setup", "async_unset_agent", diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 8d739b6267d..648a89e47f1 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -196,6 +196,7 @@ class ChatLog: extra_system_prompt: str | None = None llm_api: llm.APIInstance | None = None delta_listener: Callable[[ChatLog, dict], None] | None = None + llm_input_provided_index = 0 @property def continue_conversation(self) -> bool: @@ -496,6 +497,7 @@ class ChatLog: prompt = "\n".join(prompt_parts) + self.llm_input_provided_index = len(self.content) self.llm_api = llm_api self.extra_system_prompt = extra_system_prompt self.content[0] = SystemContent(content=prompt) diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py new file mode 100644 index 00000000000..04a5a420279 --- /dev/null +++ b/homeassistant/components/conversation/util.py @@ -0,0 +1,47 @@ +"""Utility functions for conversation integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent, llm + +from .chat_log import AssistantContent, ChatLog, ToolResultContent +from .models import ConversationInput, ConversationResult + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_get_result_from_chat_log( + user_input: ConversationInput, chat_log: ChatLog +) -> ConversationResult: + """Get the result from the chat log.""" + tool_results = [ + content.tool_result + for content in chat_log.content[chat_log.llm_input_provided_index :] + if isinstance(content, ToolResultContent) + and isinstance(content.tool_result, llm.IntentResponseDict) + ] + + if tool_results: + intent_response = tool_results[-1].original + else: + intent_response = intent.IntentResponse(language=user_input.language) + + if not isinstance((last_content := chat_log.content[-1]), AssistantContent): + _LOGGER.error( + "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + last_content, + ) + raise HomeAssistantError("Unable to get response") + + intent_response.async_set_speech(last_content.content or "") + + return ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 11bf3e3118b..ba00bcaedb9 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -48,11 +48,11 @@ SUPPORT_ALL_SERVICES = ( ) FAN_SPEEDS = ["min", "medium", "high", "max"] -DEMO_VACUUM_COMPLETE = "0_Ground_floor" -DEMO_VACUUM_MOST = "1_First_floor" -DEMO_VACUUM_BASIC = "2_Second_floor" -DEMO_VACUUM_MINIMAL = "3_Third_floor" -DEMO_VACUUM_NONE = "4_Fourth_floor" +DEMO_VACUUM_COMPLETE = "Demo vacuum 0 ground floor" +DEMO_VACUUM_MOST = "Demo vacuum 1 first floor" +DEMO_VACUUM_BASIC = "Demo vacuum 2 second floor" +DEMO_VACUUM_MINIMAL = "Demo vacuum 3 third floor" +DEMO_VACUUM_NONE = "Demo vacuum 4 fourth floor" async def async_setup_entry( diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index da2b601317a..8cead5f4992 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -53,8 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> raise ConfigEntryNotReady from ex receiver = connect_denonavr.receiver - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = receiver await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -100,10 +98,3 @@ async def async_unload_entry( _LOGGER.debug("Removing zone3 from DenonAvr") return unload_ok - - -async def update_listener( - hass: HomeAssistant, config_entry: DenonavrConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 930d0e009ac..204471a13b4 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -10,7 +10,11 @@ import denonavr from denonavr.exceptions import AvrNetworkError, AvrTimoutError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client @@ -51,7 +55,7 @@ DEFAULT_USE_TELNET_NEW_INSTALL = True CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_HOST): str}) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index ab4feabc4ee..da35975c193 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -320,7 +320,12 @@ class DerivativeSensor(RestoreSensor, SensorEntity): # changed state, then we know it will still be zero. return schedule_max_sub_interval_exceeded(new_state) - calc_derivative(new_state, new_state.state, event.data["old_last_reported"]) + calc_derivative( + new_state, + new_state.state, + event.data["last_reported"], + event.data["old_last_reported"], + ) @callback def on_state_changed(event: Event[EventStateChangedData]) -> None: @@ -334,19 +339,27 @@ class DerivativeSensor(RestoreSensor, SensorEntity): schedule_max_sub_interval_exceeded(new_state) old_state = event.data["old_state"] if old_state is not None: - calc_derivative(new_state, old_state.state, old_state.last_reported) + calc_derivative( + new_state, + old_state.state, + new_state.last_updated, + old_state.last_reported, + ) else: # On first state change from none, update availability self.async_write_ha_state() def calc_derivative( - new_state: State, old_value: str, old_last_reported: datetime + new_state: State, + old_value: str, + new_timestamp: datetime, + old_timestamp: datetime, ) -> None: """Handle the sensor state changes.""" if not _is_decimal_state(old_value): if self._last_valid_state_time: old_value = self._last_valid_state_time[0] - old_last_reported = self._last_valid_state_time[1] + old_timestamp = self._last_valid_state_time[1] else: # Sensor becomes valid for the first time, just keep the restored value self.async_write_ha_state() @@ -358,12 +371,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity): "" if unit is None else unit ) - self._prune_state_list(new_state.last_reported) + self._prune_state_list(new_timestamp) try: - elapsed_time = ( - new_state.last_reported - old_last_reported - ).total_seconds() + elapsed_time = (new_timestamp - old_timestamp).total_seconds() delta_value = Decimal(new_state.state) - Decimal(old_value) new_derivative = ( delta_value @@ -392,12 +403,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity): return # add latest derivative to the window list - self._state_list.append( - (old_last_reported, new_state.last_reported, new_derivative) - ) + self._state_list.append((old_timestamp, new_timestamp, new_derivative)) self._last_valid_state_time = ( new_state.state, - new_state.last_reported, + new_timestamp, ) # If outside of time window just report derivative (is the same as modeling it in the window), @@ -405,9 +414,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): if elapsed_time > self._time_window: derivative = new_derivative else: - derivative = self._calc_derivative_from_state_list( - new_state.last_reported - ) + derivative = self._calc_derivative_from_state_list(new_timestamp) self._write_native_value(derivative) source_state = self.hass.states.get(self._sensor_source_id) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 50177a9b13b..24bf06ac59c 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -9,7 +9,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard.", + "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network app on the device dashboard.", "password": "Password you protected the device with." } }, @@ -22,8 +22,8 @@ } }, "zeroconf_confirm": { - "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", - "title": "Discovered devolo home network device", + "description": "Do you want to add the devolo Home Network device with the hostname `{host_name}` to Home Assistant?", + "title": "Discovered devolo Home Network device", "data": { "password": "[%key:common::config_flow::data::password%]" }, diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 0a8b7422f84..65687debd3a 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import create_async_httpx_client +from .const import DOMAIN from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -30,10 +31,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) - # if no exception is raised everything is fine to go meters = await client.meters() except discovergyError.InvalidLogin as err: - raise ConfigEntryAuthFailed("Invalid email or password") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err except Exception as err: raise ConfigEntryNotReady( - "Unexpected error while while getting meters" + translation_domain=DOMAIN, + translation_key="cannot_connect_meters_setup", ) from err # Init coordinators for meters diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index e3f26ad49f8..2c77ab2388e 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -14,6 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) type DiscovergyConfigEntry = ConfigEntry[list[DiscovergyUpdateCoordinator]] @@ -51,7 +53,12 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): ) except InvalidLogin as err: raise ConfigEntryAuthFailed( - "Auth expired while fetching last reading" + translation_domain=DOMAIN, + translation_key="invalid_auth", ) from err except (HTTPError, DiscovergyClientError) as err: - raise UpdateFailed(f"Error while fetching last reading: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="reading_update_failed", + translation_placeholders={"meter_id": self.meter.meter_id}, + ) from err diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index 2f74928c19e..d3443eaefdf 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["pydiscovergy==3.0.2"] } diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index 56af1d97304..db49639b937 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -57,13 +57,16 @@ rules: status: exempt comment: | This integration cannot be discovered, it is a connecting to a cloud service. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: exempt + comment: | + The integration does not have any known limitations. + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | @@ -72,12 +75,16 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: | The integration does not provide any additional icons. - reconfiguration-flow: todo + reconfiguration-flow: + status: exempt + comment: | + No configuration besides credentials. + New credentials will create a new config entry. repair-issues: status: exempt comment: | diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index 0058f874a36..911a4a1c4f5 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -23,6 +23,17 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "exceptions": { + "invalid_auth": { + "message": "Authentication failed. Please check your inexogy email and password." + }, + "cannot_connect_meters_setup": { + "message": "Failed to connect and retrieve meters from inexogy during setup. Please ensure the service is reachable and try again." + }, + "reading_update_failed": { + "message": "Error fetching the latest reading for meter {meter_id} from inexogy. The service might be temporarily unavailable or there's a connection issue. Check logs for more details." + } + }, "system_health": { "info": { "api_endpoint_reachable": "inexogy API endpoint reachable" diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 119d1d31d52..eac8ddcf713 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 0289d5100d6..4a73bf779e0 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.44.0"], + "requirements": ["async-upnp-client==0.45.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 37e0f60849f..3487ce83c7b 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -13,15 +13,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up DNS IP from a config entry.""" await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload dnsip config entry.""" diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index ab1ca42acd3..0ea2a9d092b 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback @@ -165,7 +165,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): ) -class DnsIPOptionsFlowHandler(OptionsFlow): +class DnsIPOptionsFlowHandler(OptionsFlowWithReload): """Handle a option config flow for dnsip integration.""" async def async_step_init( diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 012abcc8c9a..1c081dc86e6 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -69,16 +69,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener(hass: HomeAssistant, entry: EmonCMSConfigEntry): - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index b14903a78f9..375077a83d4 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import callback @@ -221,7 +221,7 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EmoncmsOptionsFlow(OptionsFlow): +class EmoncmsOptionsFlow(OptionsFlowWithReload): """Emoncms Options flow handler.""" def __init__(self, config_entry: ConfigEntry) -> None: diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 278045001fc..320179bf2df 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.2.1"], + "requirements": ["pyenphase==2.2.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index dddbb598a57..eddd4d523c9 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -295,23 +295,7 @@ class RuntimeEntryData: needed_platforms.add(Platform.BINARY_SENSOR) needed_platforms.add(Platform.SELECT) - ent_reg = er.async_get(hass) - registry_get_entity = ent_reg.async_get_entity_id - for info in infos: - platform = INFO_TYPE_TO_PLATFORM[type(info)] - needed_platforms.add(platform) - # If the unique id is in the old format, migrate it - # except if they downgraded and upgraded, there might be a duplicate - # so we want to keep the one that was already there. - if ( - (old_unique_id := info.unique_id) - and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id)) - and (new_unique_id := build_device_unique_id(mac, info)) - != old_unique_id - and not registry_get_entity(platform, DOMAIN, new_unique_id) - ): - ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id) - + needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos) await self._ensure_platforms_loaded(hass, entry, needed_platforms) # Make a dict of the EntityInfo by type and send diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index c88fa7246fe..e83ab16064c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==35.0.0", + "aioesphomeapi==37.0.2", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index a93954b8a9b..65749871093 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -94,8 +94,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> boo entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. # Cameras are accessed via local RTSP stream with unique credentials per camera. # Separate camera entities allow for credential changes per camera. @@ -120,8 +118,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bo return await hass.config_entries.async_unload_platforms( entry, PLATFORMS_BY_TYPE[sensor_type] ) - - -async def _async_update_listener(hass: HomeAssistant, entry: EzvizConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 622f767443d..d90f04b403a 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -17,7 +17,11 @@ from pyezvizapi.exceptions import ( from pyezvizapi.test_cam_rtsp import TestRTSPAuth import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -386,7 +390,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EzvizOptionsFlowHandler(OptionsFlow): +class EzvizOptionsFlowHandler(OptionsFlowWithReload): """Handle EZVIZ client options.""" async def async_step_init( diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 57c58d3a2b1..9acec01ee6d 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -32,8 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) - await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - return True @@ -46,10 +44,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) if len(entries) == 1: hass.data.pop(MY_KEY) return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT]) - - -async def _async_update_listener( - hass: HomeAssistant, entry: FeedReaderConfigEntry -) -> None: - """Handle reconfiguration.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 3d0fec1a6f5..37c627f21ba 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback @@ -44,7 +44,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> FeedReaderOptionsFlowHandler: """Get the options flow for this handler.""" return FeedReaderOptionsFlowHandler() @@ -119,11 +119,10 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): errors={"base": "url_error"}, ) - self.hass.config_entries.async_update_entry(reconfigure_entry, data=user_input) - return self.async_abort(reason="reconfigure_successful") + return self.async_update_reload_and_abort(reconfigure_entry, data=user_input) -class FeedReaderOptionsFlowHandler(OptionsFlow): +class FeedReaderOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" async def async_step_init( diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 7bc206057c8..59a08715b8e 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -29,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups( entry, [Platform(entry.data[CONF_PLATFORM])] ) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -41,11 +40,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate config entry.""" if config_entry.version > 2: diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index 1c4fdbe5c84..9078a4d115e 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_FILE_PATH, @@ -131,7 +131,7 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN): return await self._async_handle_step(Platform.SENSOR.value, user_input) -class FileOptionsFlowHandler(OptionsFlow): +class FileOptionsFlowHandler(OptionsFlowWithReload): """Handle File options.""" async def async_step_init( diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 171341f7226..7b534b80500 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -47,8 +47,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_update_options)) - return True @@ -57,10 +55,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_update_options( - hass: HomeAssistant, entry: ForecastSolarConfigEntry -) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 9a64ce6e1fb..031764a0d0a 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback @@ -88,7 +88,7 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): ) -class ForecastSolarOptionFlowHandler(OptionsFlow): +class ForecastSolarOptionFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index faf82b4b516..94f4f8ba0d8 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -75,8 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo if FRITZ_DATA_KEY not in hass.data: hass.data[FRITZ_DATA_KEY] = FritzData() - entry.async_on_unload(entry.add_update_listener(update_listener)) - # Load the other platforms like switch await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -94,9 +92,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bo hass.data.pop(FRITZ_DATA_KEY) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: FritzConfigEntry) -> None: - """Update when config_entry options update.""" - if entry.options: - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 2c22a35c4dd..270e9870c63 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -17,7 +17,11 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -409,7 +413,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): ) -class FritzBoxToolsOptionsFlowHandler(OptionsFlow): +class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" async def async_step_init( diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py index b1b5db48216..ea4bf46f09c 100644 --- a/homeassistant/components/fritzbox_callmonitor/__init__.py +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -48,7 +48,6 @@ async def async_setup_entry( raise ConfigEntryNotReady from ex config_entry.runtime_data = fritzbox_phonebook - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True @@ -59,10 +58,3 @@ async def async_unload_entry( ) -> bool: """Unloading the fritzbox_callmonitor platforms.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, config_entry: FritzBoxCallMonitorConfigEntry -) -> None: - """Update listener to reload after option has changed.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index 8435eff3e18..25e25336d57 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback @@ -263,7 +263,7 @@ class FritzBoxCallMonitorConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class FritzBoxCallMonitorOptionsFlowHandler(OptionsFlow): +class FritzBoxCallMonitorOptionsFlowHandler(OptionsFlowWithReload): """Handle a fritzbox_callmonitor options flow.""" @classmethod diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9694c299b23..2f2a8e93b1e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,6 +26,7 @@ from homeassistant.const import ( EVENT_THEMES_UPDATED, ) from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted @@ -543,6 +544,12 @@ async def _async_setup_themes( """Reload themes.""" config = await async_hass_config_yaml(hass) new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {}) + + try: + THEME_SCHEMA(new_themes) + except vol.Invalid as err: + raise HomeAssistantError(f"Failed to reload themes: {err}") from err + hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a7582ebc5e2..791acf8a39c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250702.2"] + "requirements": ["home-assistant-frontend==20250702.3"] } diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index 41b4f1e79ba..342061c18d1 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -13,6 +13,7 @@ from gardena_bluetooth.parse import ( ) from homeassistant.components.number import ( + NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, @@ -54,6 +55,7 @@ DESCRIPTIONS = ( native_step=60, entity_category=EntityCategory.CONFIG, char=Valve.manual_watering_time, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=Valve.remaining_open_time.uuid, @@ -64,6 +66,7 @@ DESCRIPTIONS = ( native_step=60.0, entity_category=EntityCategory.DIAGNOSTIC, char=Valve.remaining_open_time, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=DeviceConfiguration.rain_pause.uuid, @@ -75,6 +78,7 @@ DESCRIPTIONS = ( native_step=6 * 60.0, entity_category=EntityCategory.CONFIG, char=DeviceConfiguration.rain_pause, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=DeviceConfiguration.seasonal_adjust.uuid, @@ -86,6 +90,7 @@ DESCRIPTIONS = ( native_step=1.0, entity_category=EntityCategory.CONFIG, char=DeviceConfiguration.seasonal_adjust, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=Sensor.threshold.uuid, @@ -153,6 +158,7 @@ class GardenaBluetoothRemainingOpenSetNumber(GardenaBluetoothEntity, NumberEntit _attr_native_min_value = 0.0 _attr_native_max_value = 24 * 60 _attr_native_step = 1.0 + _attr_device_class = NumberDeviceClass.DURATION def __init__( self, diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index 4138c7c4472..247a85f93f1 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -6,7 +6,11 @@ from typing import Any from gardena_bluetooth.const import Valve -from homeassistant.components.valve import ValveEntity, ValveEntityFeature +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -37,6 +41,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity): _attr_is_closed: bool | None = None _attr_reports_position = False _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_device_class = ValveDeviceClass.WATER characteristics = { Valve.state.uuid, diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 1782320a357..8c6765ece89 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==6.1.1"] + "requirements": ["gios==6.1.2"] } diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index dea2acf4f1b..df50039b03f 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -47,7 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo async_cleanup_device_registry(hass=hass, entry=entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True @@ -87,8 +86,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> b coordinator.unsubscribe() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_reload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> None: - """Handle an options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 17338119b9f..a2a7e56830f 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback @@ -214,7 +214,7 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for GitHub.""" async def async_step_init( diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 4e15b93330c..aeedb847090 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -306,6 +306,11 @@ class WebRTCProvider(CameraWebRTCProvider): await self.teardown() raise HomeAssistantError("Camera has no stream source") + if camera.platform.platform_name == "generic": + # This is a workaround to use ffmpeg for generic cameras + # A proper fix will be added in the future together with supporting multiple streams per camera + stream_source = "ffmpeg:" + stream_source + if not self.async_is_supported(stream_source): await self.teardown() raise HomeAssistantError("Stream source is not supported by go2rtc") @@ -323,7 +328,6 @@ class WebRTCProvider(CameraWebRTCProvider): # Connection problems to the camera will be logged by the first stream # Therefore setting it to debug will not hide any important logs f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 1ff9f355c06..3c1c9cad0b0 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -36,12 +36,14 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, DEFAULT_AI_TASK_NAME, + DEFAULT_STT_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, LOGGER, RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, ) @@ -55,6 +57,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = ( Platform.AI_TASK, Platform.CONVERSATION, + Platform.STT, Platform.TTS, ) @@ -301,7 +304,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: - _add_ai_task_subentry(hass, entry) + _add_ai_task_and_stt_subentries(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_TITLE, @@ -350,8 +353,7 @@ async def async_migrate_entry( hass.config_entries.async_update_entry(entry, minor_version=2) if entry.version == 2 and entry.minor_version == 2: - # Add AI Task subentry with default options - _add_ai_task_subentry(hass, entry) + _add_ai_task_and_stt_subentries(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=3) if entry.version == 2 and entry.minor_version == 3: @@ -393,10 +395,10 @@ async def async_migrate_entry( return True -def _add_ai_task_subentry( +def _add_ai_task_and_stt_subentries( hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry ) -> None: - """Add AI Task subentry to the config entry.""" + """Add AI Task and STT subentries to the config entry.""" hass.config_entries.async_add_subentry( entry, ConfigSubentry( @@ -406,3 +408,12 @@ def _add_ai_task_subentry( unique_id=None, ), ) + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_STT_OPTIONS), + subentry_type="stt", + title=DEFAULT_STT_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 7d1429b110e..e760187bc66 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -49,6 +49,8 @@ from .const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, + DEFAULT_STT_PROMPT, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, @@ -57,6 +59,8 @@ from .const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_STT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, @@ -144,6 +148,12 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): "title": DEFAULT_AI_TASK_NAME, "unique_id": None, }, + { + "subentry_type": "stt", + "data": RECOMMENDED_STT_OPTIONS, + "title": DEFAULT_STT_NAME, + "unique_id": None, + }, ], ) return self.async_show_form( @@ -191,6 +201,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Return subentries supported by this integration.""" return { "conversation": LLMSubentryFlowHandler, + "stt": LLMSubentryFlowHandler, "tts": LLMSubentryFlowHandler, "ai_task_data": LLMSubentryFlowHandler, } @@ -228,6 +239,8 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow): options = RECOMMENDED_TTS_OPTIONS.copy() elif self._subentry_type == "ai_task_data": options = RECOMMENDED_AI_TASK_OPTIONS.copy() + elif self._subentry_type == "stt": + options = RECOMMENDED_STT_OPTIONS.copy() else: options = RECOMMENDED_CONVERSATION_OPTIONS.copy() else: @@ -304,6 +317,8 @@ async def google_generative_ai_config_option_schema( default_name = DEFAULT_TTS_NAME elif subentry_type == "ai_task_data": default_name = DEFAULT_AI_TASK_NAME + elif subentry_type == "stt": + default_name = DEFAULT_STT_NAME else: default_name = DEFAULT_CONVERSATION_NAME schema: dict[vol.Required | vol.Optional, Any] = { @@ -331,6 +346,17 @@ async def google_generative_ai_config_option_schema( ), } ) + elif subentry_type == "stt": + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get(CONF_PROMPT, DEFAULT_STT_PROMPT) + }, + ): TemplateSelector(), + } + ) schema.update( { @@ -388,6 +414,8 @@ async def google_generative_ai_config_option_schema( if subentry_type == "tts": default_model = RECOMMENDED_TTS_MODEL + elif subentry_type == "stt": + default_model = RECOMMENDED_STT_MODEL else: default_model = RECOMMENDED_CHAT_MODEL diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index b7091fe0222..ba7af5147c5 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -5,18 +5,23 @@ import logging from homeassistant.const import CONF_LLM_HASS_API from homeassistant.helpers import llm +LOGGER = logging.getLogger(__package__) + DOMAIN = "google_generative_ai_conversation" DEFAULT_TITLE = "Google Generative AI" -LOGGER = logging.getLogger(__package__) -CONF_PROMPT = "prompt" DEFAULT_CONVERSATION_NAME = "Google AI Conversation" +DEFAULT_STT_NAME = "Google AI STT" DEFAULT_TTS_NAME = "Google AI TTS" DEFAULT_AI_TASK_NAME = "Google AI Task" +CONF_PROMPT = "prompt" +DEFAULT_STT_PROMPT = "Transcribe the attached audio" + CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" +RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 @@ -43,6 +48,11 @@ RECOMMENDED_CONVERSATION_OPTIONS = { CONF_RECOMMENDED: True, } +RECOMMENDED_STT_OPTIONS = { + CONF_PROMPT: DEFAULT_STT_PROMPT, + CONF_RECOMMENDED: True, +} + RECOMMENDED_TTS_OPTIONS = { CONF_RECOMMENDED: True, } diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 3525fba3af5..d804073bfb4 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -8,12 +8,10 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_PROMPT, DOMAIN, LOGGER -from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity +from .const import CONF_PROMPT, DOMAIN +from .entity import GoogleGenerativeAILLMBaseEntity async def async_setup_entry( @@ -84,16 +82,4 @@ class GoogleGenerativeAIConversationEntity( await self._async_handle_chat_log(chat_log) - response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - LOGGER.error( - "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", - chat_log.content[-1], - ) - raise HomeAssistantError(ERROR_GETTING_RESPONSE) - response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 774f41f0279..11e7c75c8ba 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -34,7 +34,7 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "recommended": "Recommended model settings", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "chat_model": "[%key:common::generic::model%]", "temperature": "Temperature", "top_p": "Top P", @@ -61,6 +61,38 @@ "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } }, + "stt": { + "initiate_flow": { + "user": "Add Speech-to-Text service", + "reconfigure": "Reconfigure Speech-to-Text service" + }, + "entry_type": "Speech-to-Text", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "prompt": "[%key:common::config_flow::data::prompt%]", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + }, + "data_description": { + "prompt": "Instruct how the LLM should transcribe the audio." + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, "tts": { "initiate_flow": { "user": "Add Text-to-Speech service", diff --git a/homeassistant/components/google_generative_ai_conversation/stt.py b/homeassistant/components/google_generative_ai_conversation/stt.py new file mode 100644 index 00000000000..bdf8a2fd7bf --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/stt.py @@ -0,0 +1,254 @@ +"""Speech to text support for Google Generative AI.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable + +from google.genai.errors import APIError, ClientError +from google.genai.types import Part + +from homeassistant.components import stt +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + DEFAULT_STT_PROMPT, + LOGGER, + RECOMMENDED_STT_MODEL, +) +from .entity import GoogleGenerativeAILLMBaseEntity +from .helpers import convert_to_wav + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up STT entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "stt": + continue + + async_add_entities( + [GoogleGenerativeAISttEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class GoogleGenerativeAISttEntity( + stt.SpeechToTextEntity, GoogleGenerativeAILLMBaseEntity +): + """Google Generative AI speech-to-text entity.""" + + def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the STT entity.""" + super().__init__(config_entry, subentry, RECOMMENDED_STT_MODEL) + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return [ + "af-ZA", + "sq-AL", + "am-ET", + "ar-DZ", + "ar-BH", + "ar-EG", + "ar-IQ", + "ar-IL", + "ar-JO", + "ar-KW", + "ar-LB", + "ar-MA", + "ar-OM", + "ar-QA", + "ar-SA", + "ar-PS", + "ar-TN", + "ar-AE", + "ar-YE", + "hy-AM", + "az-AZ", + "eu-ES", + "bn-BD", + "bn-IN", + "bs-BA", + "bg-BG", + "my-MM", + "ca-ES", + "zh-CN", + "zh-TW", + "hr-HR", + "cs-CZ", + "da-DK", + "nl-BE", + "nl-NL", + "en-AU", + "en-CA", + "en-GH", + "en-HK", + "en-IN", + "en-IE", + "en-KE", + "en-NZ", + "en-NG", + "en-PK", + "en-PH", + "en-SG", + "en-ZA", + "en-TZ", + "en-GB", + "en-US", + "et-EE", + "fil-PH", + "fi-FI", + "fr-BE", + "fr-CA", + "fr-FR", + "fr-CH", + "gl-ES", + "ka-GE", + "de-AT", + "de-DE", + "de-CH", + "el-GR", + "gu-IN", + "iw-IL", + "hi-IN", + "hu-HU", + "is-IS", + "id-ID", + "it-IT", + "it-CH", + "ja-JP", + "jv-ID", + "kn-IN", + "kk-KZ", + "km-KH", + "ko-KR", + "lo-LA", + "lv-LV", + "lt-LT", + "mk-MK", + "ms-MY", + "ml-IN", + "mr-IN", + "mn-MN", + "ne-NP", + "no-NO", + "fa-IR", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + "ru-RU", + "sr-RS", + "si-LK", + "sk-SK", + "sl-SI", + "es-AR", + "es-BO", + "es-CL", + "es-CO", + "es-CR", + "es-DO", + "es-EC", + "es-SV", + "es-GT", + "es-HN", + "es-MX", + "es-NI", + "es-PA", + "es-PY", + "es-PE", + "es-PR", + "es-ES", + "es-US", + "es-UY", + "es-VE", + "su-ID", + "sw-KE", + "sw-TZ", + "sv-SE", + "ta-IN", + "ta-MY", + "ta-SG", + "ta-LK", + "te-IN", + "th-TH", + "tr-TR", + "uk-UA", + "ur-IN", + "ur-PK", + "uz-UZ", + "vi-VN", + "zu-ZA", + ] + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + # https://ai.google.dev/gemini-api/docs/audio#supported-formats + return [stt.AudioFormats.WAV, stt.AudioFormats.OGG] + + @property + def supported_codecs(self) -> list[stt.AudioCodecs]: + """Return a list of supported codecs.""" + return [stt.AudioCodecs.PCM, stt.AudioCodecs.OPUS] + + @property + def supported_bit_rates(self) -> list[stt.AudioBitRates]: + """Return a list of supported bit rates.""" + return [stt.AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported sample rates.""" + return [stt.AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + # Per https://ai.google.dev/gemini-api/docs/audio + # If the audio source contains multiple channels, Gemini combines those channels into a single channel. + return [stt.AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream to STT service.""" + audio_data = b"" + async for chunk in stream: + audio_data += chunk + if metadata.format == stt.AudioFormats.WAV: + audio_data = convert_to_wav( + audio_data, + f"audio/L{metadata.bit_rate.value};rate={metadata.sample_rate.value}", + ) + + try: + response = await self._genai_client.aio.models.generate_content( + model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL), + contents=[ + self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT), + Part.from_bytes( + data=audio_data, + mime_type=f"audio/{metadata.format.value}", + ), + ], + config=self.create_generate_content_config(), + ) + except (APIError, ClientError, ValueError) as err: + LOGGER.error("Error during STT: %s", err) + else: + if response.text: + return stt.SpeechResult( + response.text, + stt.SpeechResultState.SUCCESS, + ) + + return stt.SpeechResult(None, stt.SpeechResultState.ERROR) diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 35e1577ae21..4f948b9b4d2 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import asdict, fields import datetime from math import floor -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from dateutil.rrule import ( DAILY, @@ -56,7 +56,12 @@ def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | N return dt_util.as_local(task.nextDue[0]).date() -FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": YEARLY} +FREQUENCY_MAP: dict[str, Literal[0, 1, 2, 3]] = { + "daily": DAILY, + "weekly": WEEKLY, + "monthly": MONTHLY, + "yearly": YEARLY, +} WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py index b364f2c67a4..f0c340785cf 100644 --- a/homeassistant/components/holiday/__init__.py +++ b/homeassistant/components/holiday/__init__.py @@ -34,16 +34,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 538d9971109..e9f16a9e4c5 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_COUNTRY from homeassistant.core import callback @@ -227,7 +227,7 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): ) -class HolidayOptionsFlowHandler(OptionsFlow): +class HolidayOptionsFlowHandler(OptionsFlowWithReload): """Handle Holiday options.""" async def async_step_init( diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index f9986e0c526..931b689fb08 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -12,6 +12,7 @@ from homematicip.device import ( FullFlushShutter, GarageDoorModuleTormatic, HoermannDrivesModule, + WiredDinRailBlind4, ) from homematicip.group import ExtendedLinkedShutterGroup @@ -48,7 +49,7 @@ async def async_setup_entry( for device in hap.home.devices: if isinstance(device, BlindModule): entities.append(HomematicipBlindModule(hap, device)) - elif isinstance(device, DinRailBlind4): + elif isinstance(device, (DinRailBlind4, WiredDinRailBlind4)): entities.extend( HomematicipMultiCoverSlats(hap, device, channel=channel) for channel in range(1, 5) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 036ffa286a3..14b5ac39310 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.7"] + "requirements": ["homematicip==2.2.0"] } diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 6c4c7091840..d270ffec72f 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -83,18 +83,9 @@ async def async_setup_entry( config_entry.runtime_data = HoneywellData(config_entry.entry_id, client, devices) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) - return True -async def update_listener( - hass: HomeAssistant, config_entry: HoneywellConfigEntry -) -> None: - """Update listener.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry( hass: HomeAssistant, config_entry: HoneywellConfigEntry ) -> bool: diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index 15199cdda24..c18bb0296aa 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback @@ -136,7 +136,7 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): return HoneywellOptionsFlowHandler() -class HoneywellOptionsFlowHandler(OptionsFlow): +class HoneywellOptionsFlowHandler(OptionsFlowWithReload): """Config flow options for Honeywell.""" async def async_step_init(self, user_input=None) -> ConfigFlowResult: diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index b4d3d2176af..ac7447bc3c0 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -70,6 +70,8 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): @property def event(self) -> CalendarEvent | None: """Return the current or next upcoming event.""" + if not self.available: + return None schedule = self.mower_attributes.calendar cursor = schedule.timeline.active_after(dt_util.now()) program_event = next(cursor, None) @@ -94,6 +96,8 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): This is only called when opening the calendar in the UI. """ + if not self.available: + return [] schedule = self.mower_attributes.calendar cursor = schedule.timeline.overlapping( start_date, diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 342f6892b2e..7fc1e628e27 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Callable from datetime import timedelta import logging +from typing import override from aioautomower.exceptions import ( ApiError, @@ -60,7 +61,12 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self._devices_last_update: set[str] = set() self._zones_last_update: dict[str, set[str]] = {} self._areas_last_update: dict[str, set[int]] = {} - self.async_add_listener(self._on_data_update) + + @override + @callback + def async_update_listeners(self) -> None: + self._on_data_update() + super().async_update_listeners() async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 150a3d18d87..3ccb098262f 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -114,6 +114,11 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]): """Get the mower attributes of the current mower.""" return self.coordinator.data[self.mower_id] + @property + def available(self) -> bool: + """Return True if the device is available.""" + return super().available and self.mower_id in self.coordinator.data + class AutomowerAvailableEntity(AutomowerBaseEntity): """Replies available when the mower is connected.""" diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index e1b355959d9..e9d023bd3cc 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -24,6 +24,9 @@ "error": { "default": "mdi:alert-circle-outline" }, + "inactive_reason": { + "default": "mdi:sleep" + }, "my_lawn_last_time_completed": { "default": "mdi:clock-outline" }, diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index fb717a5615f..d747bc00094 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==1.2.2"] + "requirements": ["aioautomower==2.0.0"] } diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 0a059fdd706..0ff72271cb9 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -7,7 +7,13 @@ import logging from operator import attrgetter from typing import TYPE_CHECKING, Any -from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons, WorkArea +from aioautomower.model import ( + InactiveReasons, + MowerAttributes, + MowerModes, + RestrictedReasons, + WorkArea, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -166,6 +172,13 @@ ERROR_KEY_LIST = list( dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) ) +INACTIVE_REASONS: list = [ + InactiveReasons.NONE, + InactiveReasons.PLANNING, + InactiveReasons.SEARCHING_FOR_SATELLITES, +] + + RESTRICTED_REASONS: list = [ RestrictedReasons.ALL_WORK_AREAS_COMPLETED, RestrictedReasons.DAILY_LIMIT, @@ -389,6 +402,14 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( option_fn=lambda data: RESTRICTED_REASONS, value_fn=attrgetter("planner.restricted_reason"), ), + AutomowerSensorEntityDescription( + key="inactive_reason", + translation_key="inactive_reason", + exists_fn=lambda data: data.capabilities.work_areas, + device_class=SensorDeviceClass.ENUM, + option_fn=lambda data: INACTIVE_REASONS, + value_fn=attrgetter("mower.inactive_reason"), + ), AutomowerSensorEntityDescription( key="work_area", translation_key="work_area", @@ -520,6 +541,11 @@ class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity): """Return the state attributes.""" return self.entity_description.extra_state_attributes_fn(self.mower_attributes) + @property + def available(self) -> bool: + """Return the available attribute of the entity.""" + return super().available and self.native_value is not None + class WorkAreaSensorEntity(WorkAreaAvailableEntity, SensorEntity): """Defining the Work area sensors with WorkAreaSensorEntityDescription.""" diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 9e808c66878..62843d67ae2 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -213,6 +213,14 @@ "zone_generator_problem": "Zone generator problem" } }, + "inactive_reason": { + "name": "Inactive reason", + "state": { + "none": "No inactivity", + "planning": "Planning", + "searching_for_satellites": "Searching for satellites" + } + }, "my_lawn_last_time_completed": { "name": "My lawn last time completed" }, diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py index 75faf1923df..d2dd7ff4fa3 100644 --- a/homeassistant/components/huum/__init__.py +++ b/homeassistant/components/huum/__init__.py @@ -2,46 +2,28 @@ from __future__ import annotations -import logging - -from huum.exceptions import Forbidden, NotAuthenticated -from huum.huum import Huum - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS - -_LOGGER = logging.getLogger(__name__) +from .const import PLATFORMS +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: HuumConfigEntry) -> bool: """Set up Huum from a config entry.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] + coordinator = HuumDataUpdateCoordinator( + hass=hass, + config_entry=config_entry, + ) - huum = Huum(username, password, session=async_get_clientsession(hass)) + await coordinator.async_config_entry_first_refresh() + config_entry.runtime_data = coordinator - try: - await huum.status() - except (Forbidden, NotAuthenticated) as err: - _LOGGER.error("Could not log in to Huum with given credentials") - raise ConfigEntryNotReady( - "Could not log in to Huum with given credentials" - ) from err - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = huum - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: HuumConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/huum/binary_sensor.py b/homeassistant/components/huum/binary_sensor.py new file mode 100644 index 00000000000..a8e094dda94 --- /dev/null +++ b/homeassistant/components/huum/binary_sensor.py @@ -0,0 +1,42 @@ +"""Sensor for door state.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HuumConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up door sensor.""" + async_add_entities( + [HuumDoorSensor(config_entry.runtime_data)], + ) + + +class HuumDoorSensor(HuumBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor.""" + + _attr_name = "Door" + _attr_device_class = BinarySensorDeviceClass.DOOR + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the BinarySensor.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_door" + + @property + def is_on(self) -> bool | None: + """Return the current value.""" + return not self.coordinator.data.door_closed diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index bbeb50a2b72..6a50137f0a7 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -7,38 +7,33 @@ from typing import Any from huum.const import SaunaStatus from huum.exceptions import SafetyException -from huum.huum import Huum -from huum.schemas import HuumStatusResponse from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HuumConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Huum sauna with config flow.""" - huum_handler = hass.data.setdefault(DOMAIN, {})[entry.entry_id] - - async_add_entities([HuumDevice(huum_handler, entry.entry_id)], True) + async_add_entities([HuumDevice(entry.runtime_data)]) -class HuumDevice(ClimateEntity): +class HuumDevice(HuumBaseEntity, ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] @@ -49,29 +44,28 @@ class HuumDevice(ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_max_temp = 110 - _attr_min_temp = 40 - _attr_has_entity_name = True _attr_name = None - _target_temperature: int | None = None - _status: HuumStatusResponse | None = None - - def __init__(self, huum_handler: Huum, unique_id: str) -> None: + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: """Initialize the heater.""" - self._attr_unique_id = unique_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name="Huum sauna", - manufacturer="Huum", - ) + super().__init__(coordinator) - self._huum_handler = huum_handler + self._attr_unique_id = coordinator.config_entry.entry_id + + @property + def min_temp(self) -> int: + """Return configured minimal temperature.""" + return self.coordinator.data.sauna_config.min_temp + + @property + def max_temp(self) -> int: + """Return configured maximum temperature.""" + return self.coordinator.data.sauna_config.max_temp @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" - if self._status and self._status.status == SaunaStatus.ONLINE_HEATING: + if self.coordinator.data.status == SaunaStatus.ONLINE_HEATING: return HVACMode.HEAT return HVACMode.OFF @@ -85,41 +79,33 @@ class HuumDevice(ClimateEntity): @property def current_temperature(self) -> int | None: """Return the current temperature.""" - if (status := self._status) is not None: - return status.temperature - return None + return self.coordinator.data.temperature @property def target_temperature(self) -> int: """Return the temperature we try to reach.""" - return self._target_temperature or int(self.min_temp) + return self.coordinator.data.target_temperature or int(self.min_temp) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" if hvac_mode == HVACMode.HEAT: await self._turn_on(self.target_temperature) elif hvac_mode == HVACMode.OFF: - await self._huum_handler.turn_off() + await self.coordinator.huum.turn_off() + await self.coordinator.async_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if temperature is None or self.hvac_mode != HVACMode.HEAT: return - self._target_temperature = temperature - if self.hvac_mode == HVACMode.HEAT: - await self._turn_on(temperature) - - async def async_update(self) -> None: - """Get the latest status data.""" - self._status = await self._huum_handler.status() - if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT: - self._target_temperature = self._status.target_temperature + await self._turn_on(temperature) + await self.coordinator.async_refresh() async def _turn_on(self, temperature: int) -> None: try: - await self._huum_handler.turn_on(temperature) + await self.coordinator.huum.turn_on(temperature) except (ValueError, SafetyException) as err: _LOGGER.error(str(err)) raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index 6a5fd96b99d..b6f7f883120 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -37,12 +37,12 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: try: - huum_handler = Huum( + huum = Huum( user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=async_get_clientsession(self.hass), ) - await huum_handler.status() + await huum.status() except (Forbidden, NotAuthenticated): # Most likely Forbidden as that is what is returned from `.status()` with bad creds _LOGGER.error("Could not log in to Huum with given credentials") diff --git a/homeassistant/components/huum/const.py b/homeassistant/components/huum/const.py index 69dea45b218..6691a2ad8b3 100644 --- a/homeassistant/components/huum/const.py +++ b/homeassistant/components/huum/const.py @@ -4,4 +4,4 @@ from homeassistant.const import Platform DOMAIN = "huum" -PLATFORMS = [Platform.CLIMATE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE] diff --git a/homeassistant/components/huum/coordinator.py b/homeassistant/components/huum/coordinator.py new file mode 100644 index 00000000000..6580ca99da7 --- /dev/null +++ b/homeassistant/components/huum/coordinator.py @@ -0,0 +1,60 @@ +"""DataUpdateCoordinator for Huum.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum +from huum.schemas import HuumStatusResponse + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +type HuumConfigEntry = ConfigEntry[HuumDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL = timedelta(seconds=30) + + +class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]): + """Class to manage fetching data from the API.""" + + config_entry: HuumConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HuumConfigEntry, + ) -> None: + """Initialize.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=config_entry, + ) + + self.huum = Huum( + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> HuumStatusResponse: + """Get the latest status data.""" + + try: + return await self.huum.status() + except (Forbidden, NotAuthenticated) as err: + _LOGGER.error("Could not log in to Huum with given credentials") + raise UpdateFailed( + "Could not log in to Huum with given credentials" + ) from err diff --git a/homeassistant/components/huum/entity.py b/homeassistant/components/huum/entity.py new file mode 100644 index 00000000000..cd30119f6fe --- /dev/null +++ b/homeassistant/components/huum/entity.py @@ -0,0 +1,24 @@ +"""Define Huum Base entity.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HuumDataUpdateCoordinator + + +class HuumBaseEntity(CoordinatorEntity[HuumDataUpdateCoordinator]): + """Huum base Entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name="Huum sauna", + manufacturer="Huum", + model="UKU WiFi", + ) diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 82b863e4e42..38001c58b35 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -1,7 +1,7 @@ { "domain": "huum", "name": "Huum", - "codeowners": ["@frwickst"], + "codeowners": ["@frwickst", "@vincentwolsink"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 0f49bacd1ef..60a53193acc 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -266,16 +266,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> assert hyperion_client if hyperion_client.instances is not None: await async_instances_to_clients_raw(hyperion_client.instances) - entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True -async def _async_entry_updated(hass: HomeAssistant, entry: HyperionConfigEntry) -> None: - """Handle entry updates.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 72e76ef8667..1ef53ad2951 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_BASE, @@ -431,7 +431,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return HyperionOptionsFlow() -class HyperionOptionsFlow(OptionsFlow): +class HyperionOptionsFlow(OptionsFlowWithReload): """Hyperion options flow.""" def _create_client(self) -> client.HyperionClient: diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index b9226276af6..0265c6c2ec0 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -1,6 +1,9 @@ { "entity": { "sensor": { + "hydrological_alert": { + "default": "mdi:alert-octagon-outline" + }, "water_flow": { "default": "mdi:waves-arrow-right" }, diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 631bce3fbc9..7b7c66a953d 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.2.0"] + "requirements": ["imgw_pib==1.4.2"] } diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 1c49bfb2dc0..7084889220c 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -4,7 +4,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any +from imgw_pib.const import HYDROLOGICAL_ALERTS_MAP, NO_ALERT from imgw_pib.model import HydrologicalData from homeassistant.components.sensor import ( @@ -28,14 +30,36 @@ from .entity import ImgwPibEntity PARALLEL_UPDATES = 0 +def gen_alert_attributes(data: HydrologicalData) -> dict[str, Any] | None: + """Generate attributes for the alert entity.""" + if data.hydrological_alert.value == NO_ALERT: + return None + + return { + "level": data.hydrological_alert.level, + "probability": data.hydrological_alert.probability, + "valid_from": data.hydrological_alert.valid_from, + "valid_to": data.hydrological_alert.valid_to, + } + + @dataclass(frozen=True, kw_only=True) class ImgwPibSensorEntityDescription(SensorEntityDescription): """IMGW-PIB sensor entity description.""" value: Callable[[HydrologicalData], StateType] + attrs: Callable[[HydrologicalData], dict[str, Any] | None] | None = None SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( + ImgwPibSensorEntityDescription( + key="hydrological_alert", + translation_key="hydrological_alert", + device_class=SensorDeviceClass.ENUM, + options=list(HYDROLOGICAL_ALERTS_MAP.values()), + value=lambda data: data.hydrological_alert.value, + attrs=gen_alert_attributes, + ), ImgwPibSensorEntityDescription( key="water_flow", translation_key="water_flow", @@ -109,3 +133,11 @@ class ImgwPibSensorEntity(ImgwPibEntity, SensorEntity): def native_value(self) -> StateType: """Return the value reported by the sensor.""" return self.entity_description.value(self.coordinator.data) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes.""" + if self.entity_description.attrs: + return self.entity_description.attrs(self.coordinator.data) + + return None diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index fc92ca573ab..7adb1673c8a 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -21,6 +21,41 @@ }, "entity": { "sensor": { + "hydrological_alert": { + "name": "Hydrological alert", + "state": { + "no_alert": "No alert", + "hydrological_drought": "Hydrological drought", + "rapid_water_level_rise": "Rapid water level rise" + }, + "state_attributes": { + "level": { + "name": "Level", + "state": { + "none": "None", + "orange": "Orange", + "red": "Red", + "yellow": "Yellow" + } + }, + "options": { + "state": { + "no_alert": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::no_alert%]", + "hydrological_drought": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::hydrological_drought%]", + "rapid_water_level_rise": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::rapid_water_level_rise%]" + } + }, + "probability": { + "name": "Probability" + }, + "valid_from": { + "name": "Valid from" + }, + "valid_to": { + "name": "Valid to" + } + } + }, "water_flow": { "name": "Water flow" }, diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 998bf35cd82..4928b4325d1 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_MODE, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, + MAX_LENGTH_STATE_STATE, SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -51,8 +52,12 @@ STORAGE_VERSION = 1 STORAGE_FIELDS: VolDictType = { vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)), - vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), - vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), + vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.All( + vol.Coerce(int), vol.Range(0, MAX_LENGTH_STATE_STATE) + ), + vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.All( + vol.Coerce(int), vol.Range(1, MAX_LENGTH_STATE_STATE) + ), vol.Optional(CONF_INITIAL, ""): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, @@ -84,8 +89,12 @@ CONFIG_SCHEMA = vol.Schema( lambda value: value or {}, { vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), - vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), + vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.All( + vol.Coerce(int), vol.Range(0, MAX_LENGTH_STATE_STATE) + ), + vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.All( + vol.Coerce(int), vol.Range(1, MAX_LENGTH_STATE_STATE) + ), vol.Optional(CONF_INITIAL): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 25181ac6149..49a032899be 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -463,7 +463,7 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state update when sub interval is configured.""" self._integrate_on_state_update_with_max_sub_interval( - None, event.data["old_state"], event.data["new_state"] + None, None, event.data["old_state"], event.data["new_state"] ) @callback @@ -472,13 +472,17 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state report when sub interval is configured.""" self._integrate_on_state_update_with_max_sub_interval( - event.data["old_last_reported"], None, event.data["new_state"] + event.data["old_last_reported"], + event.data["last_reported"], + None, + event.data["new_state"], ) @callback def _integrate_on_state_update_with_max_sub_interval( self, - old_last_reported: datetime | None, + old_timestamp: datetime | None, + new_timestamp: datetime | None, old_state: State | None, new_state: State | None, ) -> None: @@ -489,7 +493,9 @@ class IntegrationSensor(RestoreSensor): """ self._cancel_max_sub_interval_exceeded_callback() try: - self._integrate_on_state_change(old_last_reported, old_state, new_state) + self._integrate_on_state_change( + old_timestamp, new_timestamp, old_state, new_state + ) self._last_integration_trigger = _IntegrationTrigger.StateEvent self._last_integration_time = datetime.now(tz=UTC) finally: @@ -503,7 +509,7 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state change.""" return self._integrate_on_state_change( - None, event.data["old_state"], event.data["new_state"] + None, None, event.data["old_state"], event.data["new_state"] ) @callback @@ -512,12 +518,16 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state report.""" return self._integrate_on_state_change( - event.data["old_last_reported"], None, event.data["new_state"] + event.data["old_last_reported"], + event.data["last_reported"], + None, + event.data["new_state"], ) def _integrate_on_state_change( self, - old_last_reported: datetime | None, + old_timestamp: datetime | None, + new_timestamp: datetime | None, old_state: State | None, new_state: State | None, ) -> None: @@ -531,16 +541,17 @@ class IntegrationSensor(RestoreSensor): if old_state: # state has changed, we recover old_state from the event + new_timestamp = new_state.last_updated old_state_state = old_state.state - old_last_reported = old_state.last_reported + old_timestamp = old_state.last_reported else: - # event state reported without any state change + # first state or event state reported without any state change old_state_state = new_state.state self._attr_available = True self._derive_and_set_attributes_from_state(new_state) - if old_last_reported is None and old_state is None: + if old_timestamp is None and old_state is None: self.async_write_ha_state() return @@ -551,11 +562,12 @@ class IntegrationSensor(RestoreSensor): return if TYPE_CHECKING: - assert old_last_reported is not None + assert new_timestamp is not None + assert old_timestamp is not None elapsed_seconds = Decimal( - (new_state.last_reported - old_last_reported).total_seconds() + (new_timestamp - old_timestamp).total_seconds() if self._last_integration_trigger == _IntegrationTrigger.StateEvent - else (new_state.last_reported - self._last_integration_time).total_seconds() + else (new_timestamp - self._last_integration_time).total_seconds() ) area = self._method.calculate_area_with_two_states(elapsed_seconds, *states) diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index a6cd3fb151e..8bd7e5904b0 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -54,7 +54,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim @property def calc_method(self) -> str: """Return the calculation method.""" - return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) + return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) # type: ignore[no-any-return] @property def lat_adj_method(self) -> str: @@ -68,12 +68,12 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim @property def midnight_mode(self) -> str: """Return the midnight mode.""" - return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) + return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) # type: ignore[no-any-return] @property def school(self) -> str: """Return the school.""" - return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) + return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) # type: ignore[no-any-return] def get_new_prayer_times(self, for_date: date) -> dict[str, Any]: """Fetch prayer times for the specified date.""" diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 5d4603cafc0..68ca63b6bb5 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -171,7 +171,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: _LOGGER.debug("ISY Starting Event Stream and automatic updates") isy.websocket.start() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_auto_update) ) @@ -179,11 +178,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: IsyConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - @callback def _async_get_or_create_isy_device_in_registry( hass: HomeAssistant, entry: IsyConfigEntry, isy: ISY diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 2acebee8599..4f0217fd0c6 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -143,7 +143,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: IsyConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -316,7 +316,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for ISY/IoX.""" async def async_step_init( diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index 9eee4bbb363..9dc84971a21 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from functools import partial from typing import Any from jellyfin_apiclient_python import JellyfinClient @@ -12,6 +13,7 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaType, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant @@ -156,6 +158,51 @@ def fetch_items( ] +async def search_items( + hass: HomeAssistant, client: JellyfinClient, user_id: str, query: SearchMediaQuery +) -> list[BrowseMedia]: + """Search items in Jellyfin server.""" + search_result: list[BrowseMedia] = [] + + items: list[dict[str, Any]] = [] + # Search for items based on media filter classes (or all if none specified) + media_types: list[MediaClass] | list[None] = [] + if query.media_filter_classes: + media_types = query.media_filter_classes + else: + media_types = [None] + + for media_type in media_types: + items_dict: dict[str, Any] = await hass.async_add_executor_job( + partial( + client.jellyfin.search_media_items, + term=query.search_query, + media=media_type, + parent_id=query.media_content_id, + ) + ) + items.extend(items_dict.get("Items", [])) + + for item in items: + content_type: str = item["MediaType"] + + response = BrowseMedia( + media_class=CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS.get( + content_type, MediaClass.DIRECTORY + ), + media_content_id=item["Id"], + media_content_type=content_type, + title=item["Name"], + thumbnail=get_artwork_url(client, item), + can_play=bool(content_type in PLAYABLE_MEDIA_TYPES), + can_expand=item.get("IsFolder", False), + children=None, + ) + search_result.append(response) + + return search_result + + async def get_media_info( hass: HomeAssistant, client: JellyfinClient, diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index b71c0bf93c9..6f3c41d282f 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -11,12 +11,14 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, MediaType, + SearchMedia, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import parse_datetime -from .browse_media import build_item_response, build_root_response +from .browse_media import build_item_response, build_root_response, search_items from .client_wrapper import get_artwork_url from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator @@ -196,6 +198,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.SEARCH_MEDIA ) if "Mute" in commands: @@ -274,3 +277,13 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): media_content_type, media_content_id, ) + + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """Search the media player.""" + result = await search_items( + self.hass, self.coordinator.api_client, self.coordinator.user_id, query + ) + return SearchMedia(result=result) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index ec73d960140..8e01b6b6ae0 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -79,13 +79,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - async def update_listener( - hass: HomeAssistant, config_entry: JewishCalendarConfigEntry - ) -> None: - # Trigger update of states for all platforms - await hass.config_entries.async_reload(config_entry.entry_id) - - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) return True diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index e896bc90c9e..f52e14537b3 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -9,7 +9,11 @@ import zoneinfo from hdate.translator import Language import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_ELEVATION, CONF_LANGUAGE, @@ -124,7 +128,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_update_reload_and_abort(reconfigure_entry, data=user_input) -class JewishCalendarOptionsFlowHandler(OptionsFlow): +class JewishCalendarOptionsFlowHandler(OptionsFlowWithReload): """Handle Jewish Calendar options.""" async def async_step_init( diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 7986158ab50..358f9600845 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -33,8 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) -> router = KeeneticRouter(hass, entry) await router.async_setup() - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -87,11 +85,6 @@ async def async_unload_entry( return unload_ok -async def update_listener(hass: HomeAssistant, entry: KeeneticConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry): """Populate default options.""" host: str = entry.data[CONF_HOST] diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index c6095968c07..cec4796176e 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -153,7 +153,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() -class KeeneticOptionsFlowHandler(OptionsFlow): +class KeeneticOptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" config_entry: KeeneticConfigEntry diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 2f876ca855d..8b81cd49279 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -101,19 +101,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Start a reauth flow entry.async_start_reauth(hass) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - # Notify backup listeners hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" # Notify backup listeners diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index aa722d27944..059fd11999f 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, - OptionsFlow, + OptionsFlowWithReload, SubentryFlowResult, ) from homeassistant.core import callback @@ -65,7 +65,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 6fa4c8146ba..ead846735c9 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -120,8 +120,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[KNX_MODULE_KEY] = knx_module - entry.async_on_unload(entry.add_update_listener(async_update_entry)) - if CONF_KNX_EXPOSE in config: for expose_config in config[CONF_KNX_EXPOSE]: knx_module.exposures.append( @@ -174,11 +172,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update a given config entry.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove a config entry.""" diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 796c4c60201..7772f366493 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback @@ -899,7 +899,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): ) -class KNXOptionsFlow(OptionsFlow): +class KNXOptionsFlow(OptionsFlowWithReload): """Handle KNX options.""" def __init__(self, config_entry: ConfigEntry) -> None: diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 2d68b3be345..92184b4ac51 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -154,13 +154,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def update_listener( - hass: HomeAssistant, entry: LaMarzoccoConfigEntry - ) -> None: - await hass.config_entries.async_reload(entry.entry_id) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index e352e337d0b..fb968a0b4af 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_ADDRESS, @@ -363,7 +363,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): return LmOptionsFlowHandler() -class LmOptionsFlowHandler(OptionsFlow): +class LmOptionsFlowHandler(OptionsFlowWithReload): """Handles options flow for the component.""" async def async_step_init( diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index b5a4612429e..90bee0cf4e7 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -16,7 +16,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bo entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -24,8 +23,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bool: """Unload lastfm config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: LastFMConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 422c50a5fb9..47c5b0e217e 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -8,7 +8,11 @@ from typing import Any from pylast import LastFMNetwork, PyLastError, User, WSError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_API_KEY from homeassistant.core import callback from homeassistant.helpers.selector import ( @@ -155,7 +159,7 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) -class LastFmOptionsFlowHandler(OptionsFlow): +class LastFmOptionsFlowHandler(OptionsFlowWithReload): """LastFm Options flow handler.""" config_entry: LastFMConfigEntry diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 50c73f949a3..4b84a023675 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -6,6 +6,7 @@ import asyncio from letpot.client import LetPotClient from letpot.converters import CONVERTERS +from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException from letpot.models import AuthenticationInfo @@ -68,8 +69,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bo except LetPotException as exc: raise ConfigEntryNotReady from exc + device_client = LetPotDeviceClient(auth) + coordinators: list[LetPotDeviceCoordinator] = [ - LetPotDeviceCoordinator(hass, entry, auth, device) + LetPotDeviceCoordinator(hass, entry, device, device_client) for device in devices if any(converter.supports_type(device.device_type) for converter in CONVERTERS) ] @@ -92,5 +95,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> b """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): for coordinator in entry.runtime_data: - coordinator.device_client.disconnect() + await coordinator.device_client.unsubscribe( + coordinator.device.serial_number + ) return unload_ok diff --git a/homeassistant/components/letpot/binary_sensor.py b/homeassistant/components/letpot/binary_sensor.py index bfc7a5ab4a7..e5939abc24d 100644 --- a/homeassistant/components/letpot/binary_sensor.py +++ b/homeassistant/components/letpot/binary_sensor.py @@ -58,7 +58,9 @@ BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, supported_fn=( lambda coordinator: DeviceFeature.PUMP_STATUS - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotBinarySensorEntityDescription( diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index 39e49348663..0ef2c563f38 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -8,7 +8,7 @@ import logging from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException -from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus +from letpot.models import LetPotDevice, LetPotDeviceStatus from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -34,8 +34,8 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): self, hass: HomeAssistant, config_entry: LetPotConfigEntry, - info: AuthenticationInfo, device: LetPotDevice, + device_client: LetPotDeviceClient, ) -> None: """Initialize coordinator.""" super().__init__( @@ -45,9 +45,8 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): name=f"LetPot {device.serial_number}", update_interval=timedelta(minutes=10), ) - self._info = info self.device = device - self.device_client = LetPotDeviceClient(info, device.serial_number) + self.device_client = device_client def _handle_status_update(self, status: LetPotDeviceStatus) -> None: """Distribute status update to entities.""" @@ -56,7 +55,9 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): async def _async_setup(self) -> None: """Set up subscription for coordinator.""" try: - await self.device_client.subscribe(self._handle_status_update) + await self.device_client.subscribe( + self.device.serial_number, self._handle_status_update + ) except LetPotAuthenticationException as exc: raise ConfigEntryAuthFailed from exc @@ -64,7 +65,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): """Request an update from the device and wait for a status update or timeout.""" try: async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT): - await self.device_client.get_current_status() + await self.device_client.get_current_status(self.device.serial_number) except LetPotException as exc: raise UpdateFailed(exc) from exc diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index 5e2c46fee84..11d6a132a18 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -30,12 +30,13 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): def __init__(self, coordinator: LetPotDeviceCoordinator) -> None: """Initialize a LetPot entity.""" super().__init__(coordinator) + info = coordinator.device_client.device_info(coordinator.device.serial_number) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.device.serial_number)}, name=coordinator.device.name, manufacturer="LetPot", - model=coordinator.device_client.device_model_name, - model_id=coordinator.device_client.device_model_code, + model=info.model_name, + model_id=info.model_code, serial_number=coordinator.device.serial_number, ) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index d08b5f70a51..6ee6a309cac 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/letpot", "integration_type": "hub", "iot_class": "cloud_push", + "loggers": ["letpot"], "quality_scale": "bronze", - "requirements": ["letpot==0.4.0"] + "requirements": ["letpot==0.5.0"] } diff --git a/homeassistant/components/letpot/sensor.py b/homeassistant/components/letpot/sensor.py index b0b113eb063..841b8720616 100644 --- a/homeassistant/components/letpot/sensor.py +++ b/homeassistant/components/letpot/sensor.py @@ -50,7 +50,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, supported_fn=( lambda coordinator: DeviceFeature.TEMPERATURE - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotSensorEntityDescription( @@ -61,7 +63,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, supported_fn=( lambda coordinator: DeviceFeature.WATER_LEVEL - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), ) diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index 0b00318c53b..d22bc85f116 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -25,7 +25,7 @@ class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescrip """Describes a LetPot switch entity.""" value_fn: Callable[[LetPotDeviceStatus], bool | None] - set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]] + set_value_fn: Callable[[LetPotDeviceClient, str, bool], Coroutine[Any, Any, None]] SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( @@ -33,7 +33,9 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( key="alarm_sound", translation_key="alarm_sound", value_fn=lambda status: status.system_sound, - set_value_fn=lambda device_client, value: device_client.set_sound(value), + set_value_fn=( + lambda device_client, serial, value: device_client.set_sound(serial, value) + ), entity_category=EntityCategory.CONFIG, supported_fn=lambda coordinator: coordinator.data.system_sound is not None, ), @@ -41,25 +43,35 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( key="auto_mode", translation_key="auto_mode", value_fn=lambda status: status.water_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_water_mode(value), + set_value_fn=( + lambda device_client, serial, value: device_client.set_water_mode( + serial, value + ) + ), entity_category=EntityCategory.CONFIG, supported_fn=( lambda coordinator: DeviceFeature.PUMP_AUTO - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotSwitchEntityDescription( key="power", translation_key="power", value_fn=lambda status: status.system_on, - set_value_fn=lambda device_client, value: device_client.set_power(value), + set_value_fn=lambda device_client, serial, value: device_client.set_power( + serial, value + ), entity_category=EntityCategory.CONFIG, ), LetPotSwitchEntityDescription( key="pump_cycling", translation_key="pump_cycling", value_fn=lambda status: status.pump_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_pump_mode(value), + set_value_fn=lambda device_client, serial, value: device_client.set_pump_mode( + serial, value + ), entity_category=EntityCategory.CONFIG, ), ) @@ -104,11 +116,13 @@ class LetPotSwitchEntity(LetPotEntity, SwitchEntity): @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.set_value_fn(self.coordinator.device_client, True) + await self.entity_description.set_value_fn( + self.coordinator.device_client, self.coordinator.device.serial_number, True + ) @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_description.set_value_fn( - self.coordinator.device_client, False + self.coordinator.device_client, self.coordinator.device.serial_number, False ) diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index bae61df6a28..87ce35f828d 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -26,7 +26,7 @@ class LetPotTimeEntityDescription(TimeEntityDescription): """Describes a LetPot time entity.""" value_fn: Callable[[LetPotDeviceStatus], time | None] - set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]] + set_value_fn: Callable[[LetPotDeviceClient, str, time], Coroutine[Any, Any, None]] TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( @@ -34,8 +34,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( key="light_schedule_end", translation_key="light_schedule_end", value_fn=lambda status: None if status is None else status.light_schedule_end, - set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( - start=None, end=value + set_value_fn=( + lambda device_client, serial, value: device_client.set_light_schedule( + serial=serial, start=None, end=value + ) ), entity_category=EntityCategory.CONFIG, ), @@ -43,8 +45,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( key="light_schedule_start", translation_key="light_schedule_start", value_fn=lambda status: None if status is None else status.light_schedule_start, - set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( - start=value, end=None + set_value_fn=( + lambda device_client, serial, value: device_client.set_light_schedule( + serial=serial, start=value, end=None + ) ), entity_category=EntityCategory.CONFIG, ), @@ -89,5 +93,5 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity): async def async_set_value(self, value: time) -> None: """Set the time.""" await self.entity_description.set_value_fn( - self.coordinator.device_client, value + self.coordinator.device_client, self.coordinator.device.serial_number, value ) diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index 6218c733f4c..c0b478e895d 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -2,6 +2,9 @@ "entity_component": { "_": { "default": "mdi:lightbulb", + "state": { + "off": "mdi:lightbulb-off" + }, "state_attributes": { "effect": { "default": "mdi:circle-medium", diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index d0c6bcabfcf..b2cb7d76e8f 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1041,7 +1041,8 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self.state in { MediaPlayerState.OFF, - MediaPlayerState.STANDBY, + # Not comparing to MediaPlayerState.STANDBY to avoid deprecation warning + "standby", }: await self.async_turn_on() else: diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 8d85d7cd106..f842ccccb65 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -5,6 +5,7 @@ from functools import partial from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + EnumWithDeprecatedMembers, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, @@ -50,7 +51,13 @@ ATTR_SOUND_MODE_LIST = "sound_mode_list" DOMAIN = "media_player" -class MediaPlayerState(StrEnum): +class MediaPlayerState( + StrEnum, + metaclass=EnumWithDeprecatedMembers, + deprecated={ + "STANDBY": ("MediaPlayerState.OFF or MediaPlayerState.IDLE", "2026.8.0"), + }, +): """State of media player entities.""" OFF = "off" diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 17fc411bf20..d5f80d442a4 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -47,7 +47,6 @@ async def async_setup_entry( config_entry.runtime_data = coordinator - config_entry.async_on_unload(config_entry.add_update_listener(async_update_entry)) config_entry.async_on_unload(coordinator.untrack_home) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -64,11 +63,6 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def async_update_entry(hass: HomeAssistant, config_entry: MetWeatherConfigEntry): - """Reload Met component when options changed.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def cleanup_old_device(hass: HomeAssistant) -> None: """Cleanup device without proper device identifier.""" device_reg = dr.async_get(hass) diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index e5db80b2997..54d528a7406 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_ELEVATION, @@ -147,7 +147,7 @@ class MetConfigFlowHandler(ConfigFlow, domain=DOMAIN): return MetOptionsFlowHandler() -class MetOptionsFlowHandler(OptionsFlow): +class MetOptionsFlowHandler(OptionsFlowWithReload): """Options flow for Met component.""" async def async_step_init( diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index c68b13eeca8..a94d3b4b64e 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -83,12 +83,12 @@ class MikrotikData: @property def arp_enabled(self) -> bool: """Return arp_ping option setting.""" - return self.config_entry.options.get(CONF_ARP_PING, False) + return self.config_entry.options.get(CONF_ARP_PING, False) # type: ignore[no-any-return] @property def force_dhcp(self) -> bool: """Return force_dhcp option setting.""" - return self.config_entry.options.get(CONF_FORCE_DHCP, False) + return self.config_entry.options.get(CONF_FORCE_DHCP, False) # type: ignore[no-any-return] def get_info(self, param: str) -> str: """Return device model name.""" diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index c426b942af5..e252338d4d8 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -1,15 +1,93 @@ """Calculates mold growth indication from temperature and humidity.""" +from __future__ import annotations + +from collections.abc import Callable +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device import ( + async_entity_id_to_device_id, + async_remove_stale_devices_links_keep_entity_device, +) +from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) + +from .const import CONF_INDOOR_HUMIDITY, CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Mold indicator from a config entry.""" + # This can be removed in HA Core 2026.2 + async_remove_stale_devices_links_keep_entity_device( + hass, entry.entry_id, entry.options[CONF_INDOOR_HUMIDITY] + ) + + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_INDOOR_HUMIDITY: source_entity_id}, + ) + + entry.async_on_unload( + # We use async_handle_source_entity_changes to track changes to the humidity + # sensor, but not the temperature sensors because the mold_indicator links + # to the humidity sensor's device. + async_handle_source_entity_changes( + hass, + add_helper_config_entry_to_device=False, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_INDOOR_HUMIDITY] + ), + source_entity_id_or_uuid=entry.options[CONF_INDOOR_HUMIDITY], + ) + ) + + for temp_sensor in (CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP): + + def get_temp_sensor_updater( + temp_sensor: str, + ) -> Callable[[Event[er.EventEntityRegistryUpdatedData]], None]: + """Return a function to update the config entry with the new temp sensor.""" + + @callback + def async_sensor_updated( + event: Event[er.EventEntityRegistryUpdatedData], + ) -> None: + """Handle entity registry update.""" + data = event.data + if data["action"] != "update": + return + if "entity_id" not in data["changes"]: + return + + # Entity_id changed, update the config entry + hass.config_entries.async_update_entry( + entry, + options={**entry.options, temp_sensor: data["entity_id"]}, + ) + + return async_sensor_updated + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, entry.options[temp_sensor], get_temp_sensor_updater(temp_sensor) + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -24,3 +102,40 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + if config_entry.minor_version < 2: + # Remove the mold indicator config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, config_entry.options[CONF_INDOOR_HUMIDITY] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, version=1, minor_version=2 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py index 5e5512a60bf..d370752fff9 100644 --- a/homeassistant/components/mold_indicator/config_flow.py +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -101,6 +101,9 @@ class MoldIndicatorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + VERSION = 1 + MINOR_VERSION = 2 + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 451cc65fb55..62906ea65ae 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -35,7 +35,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -173,7 +173,7 @@ class MoldIndicator(SensorEntity): self._indoor_hum: float | None = None self._crit_temp: float | None = None if indoor_humidity_sensor: - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, indoor_humidity_sensor, ) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 2abcc273e23..9c4d1a97f00 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -120,8 +120,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True @@ -145,8 +143,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> multicast.Stop_listen() return unload_ok - - -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index 954f9e25c21..8323c0e1995 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback @@ -38,7 +38,7 @@ CONFIG_SCHEMA = vol.Schema( ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 3e4ad53d200..fec176847da 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -277,11 +277,6 @@ def _add_camera( ) -async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle entry updates.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up motionEye from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -382,7 +377,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.async_add_listener(_async_process_motioneye_cameras) ) await coordinator.async_refresh() - entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 80a6449a22d..7704fb68412 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_URL, CONF_WEBHOOK_ID from homeassistant.core import callback @@ -186,7 +186,7 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): return MotionEyeOptionsFlow() -class MotionEyeOptionsFlow(OptionsFlow): +class MotionEyeOptionsFlow(OptionsFlowWithReload): """motionEye options flow.""" async def async_step_init( diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index a3cf2d1d12f..52f00c82c27 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Mapping from copy import deepcopy from dataclasses import dataclass from enum import IntEnum +import json import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError @@ -24,6 +25,7 @@ from cryptography.hazmat.primitives.serialization import ( ) from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate import voluptuous as vol +import yaml from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass @@ -78,6 +80,7 @@ from homeassistant.const import ( CONF_PORT, CONF_PROTOCOL, CONF_STATE_TEMPLATE, + CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -321,6 +324,10 @@ SET_CLIENT_CERT = "set_client_cert" BOOLEAN_SELECTOR = BooleanSelector() TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) +TEXT_SELECTOR_READ_ONLY = TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, read_only=True) +) +URL_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.URL)) PUBLISH_TOPIC_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) PORT_SELECTOR = vol.All( NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535)), @@ -400,6 +407,7 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector( ) ) TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) +TEMPLATE_SELECTOR_READ_ONLY = TemplateSelector(TemplateSelectorConfig(read_only=True)) SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( { @@ -556,6 +564,8 @@ SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( ) ) +EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY} + @callback def validate_cover_platform_config( @@ -3102,8 +3112,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): menu_options.append("delete_entity") menu_options.extend(["device", "availability"]) self._async_update_component_data_defaults() - if self._subentry_data != self._get_reconfigure_subentry().data: - menu_options.append("save_changes") + menu_options.append( + "save_changes" + if self._subentry_data != self._get_reconfigure_subentry().data + else "export" + ) return self.async_show_menu( step_id="summary_menu", menu_options=menu_options, @@ -3145,6 +3158,117 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): title=self._subentry_data[CONF_DEVICE][CONF_NAME], ) + async def async_step_export( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config as YAML or discovery payload.""" + return self.async_show_menu( + step_id="export", + menu_options=["export_yaml", "export_discovery"], + ) + + async def async_step_export_yaml( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config as YAML.""" + if user_input is not None: + return await self.async_step_summary_menu() + + subentry = self._get_reconfigure_subentry() + mqtt_yaml_config_base: dict[str, list[dict[str, dict[str, Any]]]] = {DOMAIN: []} + mqtt_yaml_config = mqtt_yaml_config_base[DOMAIN] + + for component_id, component_data in self._subentry_data["components"].items(): + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = f"{subentry.subentry_id}_{component_id}" + component_config[CONF_DEVICE] = { + key: value + for key, value in self._subentry_data["device"].items() + if key != "mqtt_settings" + } | {"identifiers": [subentry.subentry_id]} + platform = component_config.pop(CONF_PLATFORM) + component_config.update(self._subentry_data.get("availability", {})) + component_config.update( + self._subentry_data["device"].get("mqtt_settings", {}).copy() + ) + for field in EXCLUDE_FROM_CONFIG_IF_NONE: + if field in component_config and component_config[field] is None: + component_config.pop(field) + mqtt_yaml_config.append({platform: component_config}) + + yaml_config = yaml.dump(mqtt_yaml_config_base) + data_schema = vol.Schema( + { + vol.Optional("yaml"): TEMPLATE_SELECTOR_READ_ONLY, + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema=data_schema, + suggested_values={"yaml": yaml_config}, + ) + return self.async_show_form( + step_id="export_yaml", + last_step=False, + data_schema=data_schema, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/mqtt/" + }, + ) + + async def async_step_export_discovery( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config dor MQTT discovery.""" + + if user_input is not None: + return await self.async_step_summary_menu() + + subentry = self._get_reconfigure_subentry() + discovery_topic = f"homeassistant/device/{subentry.subentry_id}/config" + discovery_payload: dict[str, Any] = {} + discovery_payload.update(self._subentry_data.get("availability", {})) + discovery_payload["dev"] = { + key: value + for key, value in self._subentry_data["device"].items() + if key != "mqtt_settings" + } | {"identifiers": [subentry.subentry_id]} + discovery_payload["o"] = {"name": "MQTT subentry export"} + discovery_payload["cmps"] = {} + + for component_id, component_data in self._subentry_data["components"].items(): + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = f"{subentry.subentry_id}_{component_id}" + component_config.update(self._subentry_data.get("availability", {})) + component_config.update( + self._subentry_data["device"].get("mqtt_settings", {}).copy() + ) + for field in EXCLUDE_FROM_CONFIG_IF_NONE: + if field in component_config and component_config[field] is None: + component_config.pop(field) + discovery_payload["cmps"][component_id] = component_config + + data_schema = vol.Schema( + { + vol.Optional("discovery_topic"): TEXT_SELECTOR_READ_ONLY, + vol.Optional("discovery_payload"): TEMPLATE_SELECTOR_READ_ONLY, + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema=data_schema, + suggested_values={ + "discovery_topic": discovery_topic, + "discovery_payload": json.dumps(discovery_payload, indent=2), + }, + ) + return self.async_show_form( + step_id="export_discovery", + last_step=False, + data_schema=data_schema, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/mqtt/" + }, + ) + @callback def async_is_pem_data(data: bytes) -> bool: diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index f1594a7b034..f0e7f915551 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -247,6 +247,58 @@ def async_setup_entity_entry_helper( """Set up entity creation dynamically through MQTT discovery.""" mqtt_data = hass.data[DATA_MQTT] + @callback + def _async_migrate_subentry( + config: dict[str, Any], raw_config: dict[str, Any], migration_type: str + ) -> bool: + """Start a repair flow to allow migration of MQTT device subentries. + + If a YAML config or discovery is detected using the ID + of an existing mqtt subentry, and exported configuration is detected, + and a repair flow is offered to migrate the subentry. + """ + if ( + CONF_DEVICE in config + and CONF_IDENTIFIERS in config[CONF_DEVICE] + and config[CONF_DEVICE][CONF_IDENTIFIERS] + and (subentry_id := config[CONF_DEVICE][CONF_IDENTIFIERS][0]) + in entry.subentries + ): + name: str = config[CONF_DEVICE].get(CONF_NAME, "-") + if migration_type == "subentry_migration_yaml": + _LOGGER.info( + "Starting migration repair flow for MQTT subentry %s " + "for migration to YAML config: %s", + subentry_id, + raw_config, + ) + elif migration_type == "subentry_migration_discovery": + _LOGGER.info( + "Starting migration repair flow for MQTT subentry %s " + "for migration to configuration via MQTT discovery: %s", + subentry_id, + raw_config, + ) + async_create_issue( + hass, + DOMAIN, + subentry_id, + issue_domain=DOMAIN, + is_fixable=True, + severity=IssueSeverity.WARNING, + learn_more_url=learn_more_url(domain), + data={ + "entry_id": entry.entry_id, + "subentry_id": subentry_id, + "name": name, + }, + translation_placeholders={"name": name}, + translation_key=migration_type, + ) + return True + + return False + @callback def _async_setup_entity_entry_from_discovery( discovery_payload: MQTTDiscoveryPayload, @@ -263,9 +315,22 @@ def async_setup_entity_entry_helper( entity_class = schema_class_mapping[config[CONF_SCHEMA]] if TYPE_CHECKING: assert entity_class is not None - async_add_entities( - [entity_class(hass, config, entry, discovery_payload.discovery_data)] - ) + if _async_migrate_subentry( + config, discovery_payload, "subentry_migration_discovery" + ): + _handle_discovery_failure(hass, discovery_payload) + _LOGGER.debug( + "MQTT discovery skipped, as device exists in subentry, " + "and repair flow must be completed first" + ) + else: + async_add_entities( + [ + entity_class( + hass, config, entry, discovery_payload.discovery_data + ) + ] + ) except vol.Invalid as err: _handle_discovery_failure(hass, discovery_payload) async_handle_schema_error(discovery_payload, err) @@ -346,6 +411,11 @@ def async_setup_entity_entry_helper( entity_class = schema_class_mapping[config[CONF_SCHEMA]] if TYPE_CHECKING: assert entity_class is not None + if _async_migrate_subentry( + config, yaml_config, "subentry_migration_yaml" + ): + continue + entities.append(entity_class(hass, config, entry, None)) except vol.Invalid as exc: error = str(exc) diff --git a/homeassistant/components/mqtt/repairs.py b/homeassistant/components/mqtt/repairs.py new file mode 100644 index 00000000000..6a002904f11 --- /dev/null +++ b/homeassistant/components/mqtt/repairs.py @@ -0,0 +1,74 @@ +"""Repairs for MQTT.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + + +class MQTTDeviceEntryMigration(RepairsFlow): + """Handler to remove subentry for migrated MQTT device.""" + + def __init__(self, entry_id: str, subentry_id: str, name: str) -> None: + """Initialize the flow.""" + self.entry_id = entry_id + self.subentry_id = subentry_id + self.name = name + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + device_registry = dr.async_get(self.hass) + subentry_device = device_registry.async_get_device( + identifiers={(DOMAIN, self.subentry_id)} + ) + entry = self.hass.config_entries.async_get_entry(self.entry_id) + if TYPE_CHECKING: + assert entry is not None + assert subentry_device is not None + self.hass.config_entries.async_remove_subentry(entry, self.subentry_id) + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={"name": self.name}, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if TYPE_CHECKING: + assert data is not None + entry_id = data["entry_id"] + subentry_id = data["subentry_id"] + name = data["name"] + if TYPE_CHECKING: + assert isinstance(entry_id, str) + assert isinstance(subentry_id, str) + assert isinstance(name, str) + return MQTTDeviceEntryMigration( + entry_id=entry_id, + subentry_id=subentry_id, + name=name, + ) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 783a0b30b14..83679894d71 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -98,6 +98,12 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"together with state class `{state_class}`" ) + unit_of_measurement: str | None + if ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is not None and not unit_of_measurement.strip(): + config.pop(CONF_UNIT_OF_MEASUREMENT) + # Only allow `options` to be set for `enum` sensors # to limit the possible sensor values if (options := config.get(CONF_OPTIONS)) is not None: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 96b5bd15d28..8cb66270331 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -3,6 +3,28 @@ "invalid_platform_config": { "title": "Invalid config found for MQTT {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." + }, + "subentry_migration_discovery": { + "title": "MQTT device \"{name}\" subentry migration to MQTT discovery", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::mqtt::issues::subentry_migration_discovery::title%]", + "description": "Exported MQTT device \"{name}\" identified via MQTT discovery. Select **Submit** to confirm that the MQTT device is to be migrated to the main MQTT configuration, and to remove the existing MQTT device subentry. Make sure that the discovery is retained at the MQTT broker, or is resent after the subentry is removed, so that the MQTT device will be set up correctly. As an alternative you can change the device identifiers and entity unique ID-s in your MQTT discovery configuration payload, and cancel this repair if you want to keep the MQTT device subentry." + } + } + } + }, + "subentry_migration_yaml": { + "title": "MQTT device \"{name}\" subentry migration to YAML", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::mqtt::issues::subentry_migration_yaml::title%]", + "description": "Exported MQTT device \"{name}\" identified in YAML configuration. Select **Submit** to confirm that the MQTT device is to be migrated to main MQTT config entry, and to remove the existing MQTT device subentry. As an alternative you can change the device identifiers and entity unique ID-s in your configuration.yaml file, and cancel this repair if you want to keep the MQTT device subentry." + } + } + } } }, "config": { @@ -107,10 +129,10 @@ "config_subentries": { "device": { "initiate_flow": { - "user": "Add MQTT Device", - "reconfigure": "Reconfigure MQTT Device" + "user": "Add MQTT device", + "reconfigure": "Reconfigure MQTT device" }, - "entry_type": "MQTT Device", + "entry_type": "MQTT device", "step": { "availability": { "title": "Availability options", @@ -175,6 +197,7 @@ "delete_entity": "Delete an entity", "availability": "Configure availability", "device": "Update device properties", + "export": "Export MQTT device configuration", "save_changes": "Save changes" } }, @@ -627,6 +650,36 @@ } } } + }, + "export": { + "title": "Export MQTT device config", + "description": "An export allows you to migrate the MQTT device configuration to YAML-based configuration or MQTT discovery. The configuration export can also be helpful for troubleshooting.", + "menu_options": { + "export_discovery": "Export MQTT discovery information", + "export_yaml": "Export to YAML configuration" + } + }, + "export_yaml": { + "title": "[%key:component::mqtt::config_subentries::device::step::export::title%]", + "description": "You can copy the configuration below and place it your configuration.yaml file. Home Assistant will detect if the setup of the MQTT device was tried via YAML instead, and will offer a repair flow to clean up the redundant subentry. You can also choose to change the identifiers if you do not want to remove the subentry.", + "data": { + "yaml": "Copy the YAML configuration below:" + }, + "data_description": { + "yaml": "Place YAML configuration in your [configuration.yaml]({url}#yaml-configuration-listed-per-item)." + } + }, + "export_discovery": { + "title": "[%key:component::mqtt::config_subentries::device::step::export::title%]", + "description": "To allow setup via MQTT [discovery]({url}#device-discovery-payload), the discovery payload needs to be published to the discovery topic. Copy the information from the fields below. Home Assistant will detect if the setup of the MQTT device was tried via MQTT discovery instead, and will offer a repair flow to clean up the redundant subentry. You can also choose to change the identifiers if you do not want to remove the subentry.", + "data": { + "discovery_topic": "Discovery topic", + "discovery_payload": "Discovery payload:" + }, + "data_description": { + "discovery_topic": "The [discovery topic]({url}#discovery-topic) to publish the discovery payload, used to trigger MQTT discovery. An empty payload published to this topic will remove the device and discovered entities.", + "discovery_payload": "The JSON [discovery payload]({url}#device-discovery-payload) that contains information about the MQTT device." + } } }, "abort": { diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json index 2e1ea55ffcb..73b91768374 100644 --- a/homeassistant/components/nasweb/strings.json +++ b/homeassistant/components/nasweb/strings.json @@ -15,7 +15,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_internal_url": "Make sure Home Assistant has a valid internal URL", - "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant.", + "missing_nasweb_data": "Something isn't right with the device's internal configuration. Try restarting the device and Home Assistant.", "missing_status": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device.", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -25,13 +25,13 @@ }, "exceptions": { "config_entry_error_invalid_authentication": { - "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create a new one with the correct username/password." + "message": "Invalid username/password. Most likely the user has changed their password or has been removed. Delete this entry and create a new one with the correct username/password." }, "config_entry_error_internal_error": { - "message": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}" + "message": "Something isn't right with the device's internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}" }, "config_entry_error_no_status_update": { - "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" + "message": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" }, "config_entry_error_missing_internal_url": { "message": "[%key:component::nasweb::config::error::missing_internal_url%]" @@ -43,7 +43,7 @@ "entity": { "switch": { "switch_output": { - "name": "Relay Switch {index}" + "name": "Relay switch {index}" } }, "sensor": { @@ -52,8 +52,8 @@ "state": { "undefined": "Undefined", "tamper": "Tamper", - "active": "Active", - "normal": "Normal", + "active": "[%key:common::state::active%]", + "normal": "[%key:common::state::normal%]", "problem": "Problem" } } diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index fa18c3510ba..9aafa482faf 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -61,8 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) - entry.async_on_unload(entry.add_update_listener(update_listener)) - async def async_update_devices() -> bool: """Fetch data from the router.""" if router.track_devices: @@ -194,11 +192,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index a0a5b76eee5..3386d07cc6d 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -65,7 +65,7 @@ def _ordered_shared_schema(schema_input): } -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index e074f7ad000..f9b23faa234 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -37,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -49,8 +47,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def _async_update_listener(hass: HomeAssistant, entry: NinaConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 24c016e5e64..f7bc0914481 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -165,8 +165,8 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(OptionsFlow): - """Handle a option flow for nut.""" +class OptionsFlowHandler(OptionsFlowWithReload): + """Handle an option flow for NINA.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 72bf9284573..2aa77e09d16 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -88,16 +88,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: devices = domain_data.setdefault(NMAP_TRACKED_DEVICES, NmapTrackedDevices()) scanner = domain_data[entry.entry_id] = NmapDeviceScanner(hass, entry, devices) await scanner.async_setup() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 1f436edd60c..e3d1ecbdb14 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback @@ -138,7 +138,7 @@ async def _async_build_schema_with_user_input( return vol.Schema(schema) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for homekit.""" def __init__(self, config_entry: ConfigEntry) -> None: diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 3bbf46f0264..7c886c534cb 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -42,8 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(options_update_listener)) - await hub.start() return True @@ -58,10 +56,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def options_update_listener( - hass: HomeAssistant, config_entry: ConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 7e1ae4c1d9b..05ece456f15 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import callback @@ -173,7 +173,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -187,7 +187,7 @@ class NoboHubConnectError(HomeAssistantError): self.msg = msg -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handles options flow for the component.""" async def async_step_init(self, user_input=None) -> ConfigFlowResult: diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 3e9d3448af2..054f888ba33 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -39,6 +39,7 @@ from .const import ( # noqa: F401 DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, DOMAIN, SERVICE_SET_VALUE, diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 2f2c6badc4c..e3460f5a687 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -116,7 +116,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: _LOGGER.debug("NUT Sensors Available: %s", status if status else None) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) unique_id = _unique_id_from_status(status) if unique_id is None: unique_id = entry.entry_id @@ -199,11 +198,6 @@ async def async_remove_config_entry_device( ) -async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def _manufacturer_from_status(status: dict[str, str]) -> str | None: """Find the best manufacturer value from the status.""" return ( diff --git a/homeassistant/components/ollama/ai_task.py b/homeassistant/components/ollama/ai_task.py index d796b28aac8..43c50abd16a 100644 --- a/homeassistant/components/ollama/ai_task.py +++ b/homeassistant/components/ollama/ai_task.py @@ -39,7 +39,10 @@ class OllamaTaskEntity( ): """Ollama AI Task entity.""" - _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) async def _async_generate_data( self, diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index e0b64702cb4..cba8559e826 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -8,7 +8,6 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OllamaConfigEntry @@ -84,15 +83,4 @@ class OllamaConversationEntity( await self._async_handle_chat_log(chat_log) - # Create intent response - intent_response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - raise TypeError( - f"Unexpected last message type: {type(chat_log.content[-1])}" - ) - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index 4122d0c67d8..b2f0ebbb7b8 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -106,9 +106,18 @@ def _convert_content( ], ) if isinstance(chat_content, conversation.UserContent): + images: list[ollama.Image] = [] + for attachment in chat_content.attachments or (): + if not attachment.mime_type.startswith("image/"): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_attachment_type", + ) + images.append(ollama.Image(value=attachment.path)) return ollama.Message( role=MessageRole.USER.value, content=chat_content.content, + images=images or None, ) if isinstance(chat_content, conversation.SystemContent): return ollama.Message( diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 4261b2286bf..4f3cb3c30c0 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -28,7 +28,7 @@ "data": { "model": "Model", "name": "[%key:common::config_flow::data::name%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "max_history": "Max history messages", "num_ctx": "Context window size", @@ -67,7 +67,7 @@ "data": { "model": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::model%]", "name": "[%key:common::config_flow::data::name%]", - "prompt": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::prompt%]", + "prompt": "[%key:common::config_flow::data::prompt%]", "max_history": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::max_history%]", "num_ctx": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::num_ctx%]", "keep_alive": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::keep_alive%]", @@ -94,5 +94,10 @@ "download": "[%key:component::ollama::config_subentries::conversation::progress::download%]" } } + }, + "exceptions": { + "unsupported_attachment_type": { + "message": "Ollama only supports image attachments in user content, but received non-image attachment." + } } } diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a897d04562f..a89a98a7fcf 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -317,7 +317,7 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): class WaitIntegrationOnboardingView(NoAuthBaseOnboardingView): - """Get backup info view.""" + """View to wait for an integration.""" url = "/api/onboarding/integration/wait" name = "api:onboarding:integration:wait" diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index c77d87d91b9..396539d93e3 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -39,8 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b onewire_hub.schedule_scan_for_new_devices() - entry.async_on_unload(entry.add_update_listener(options_update_listener)) - return True @@ -59,11 +57,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS) - - -async def options_update_listener( - hass: HomeAssistant, entry: OneWireConfigEntry -) -> None: - """Handle options update.""" - _LOGGER.debug("Configuration options updated, reloading OneWire integration") - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 2099d9aabb5..0f2a2b6c51c 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -8,7 +8,11 @@ from typing import Any from pyownet import protocol import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -160,7 +164,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return OnewireOptionsFlowHandler(config_entry) -class OnewireOptionsFlowHandler(OptionsFlow): +class OnewireOptionsFlowHandler(OptionsFlowWithReload): """Handle OneWire Config options.""" configurable_devices: dict[str, str] diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index 67ed4162778..d0f93012eb7 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -47,7 +47,6 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bool: """Set up the Onkyo config entry.""" - entry.async_on_unload(entry.add_update_listener(update_listener)) host = entry.data[CONF_HOST] @@ -82,8 +81,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bo receiver.conn.close() return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: OnkyoConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 85ff0de3251..2b8f9981e4a 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST from homeassistant.core import callback @@ -329,7 +329,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithReload: """Return the options flow.""" return OnkyoOptionsFlowHandler() @@ -357,7 +357,7 @@ OPTIONS_STEP_INIT_SCHEMA = vol.Schema( ) -class OnkyoOptionsFlowHandler(OptionsFlow): +class OnkyoOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for Onkyo.""" _data: dict[str, Any] diff --git a/homeassistant/components/open_router/__init__.py b/homeassistant/components/open_router/__init__.py new file mode 100644 index 00000000000..477fabca54c --- /dev/null +++ b/homeassistant/components/open_router/__init__.py @@ -0,0 +1,58 @@ +"""The OpenRouter integration.""" + +from __future__ import annotations + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client + +from .const import LOGGER + +PLATFORMS = [Platform.CONVERSATION] + +type OpenRouterConfigEntry = ConfigEntry[AsyncOpenAI] + + +async def async_setup_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool: + """Set up OpenRouter from a config entry.""" + client = AsyncOpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=entry.data[CONF_API_KEY], + http_client=get_async_client(hass), + ) + + # Cache current platform data which gets added to each request (caching done by library) + _ = await hass.async_add_executor_job(client.platform_headers) + + try: + async for _ in client.with_options(timeout=10.0).models.list(): + break + except AuthenticationError as err: + LOGGER.error("Invalid API key: %s", err) + raise ConfigEntryError("Invalid API key") from err + except OpenAIError as err: + raise ConfigEntryNotReady(err) from err + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener( + hass: HomeAssistant, entry: OpenRouterConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool: + """Unload OpenRouter.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py new file mode 100644 index 00000000000..e228492e3a1 --- /dev/null +++ b/homeassistant/components/open_router/config_flow.py @@ -0,0 +1,142 @@ +"""Config flow for OpenRouter integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from openai import AsyncOpenAI +from python_open_router import OpenRouterClient, OpenRouterError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL +from homeassistant.core import callback +from homeassistant.helpers import llm +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS + +_LOGGER = logging.getLogger(__name__) + + +class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenRouter.""" + + VERSION = 1 + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"conversation": ConversationFlowHandler} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + client = OpenRouterClient( + user_input[CONF_API_KEY], async_get_clientsession(self.hass) + ) + try: + await client.get_key_data() + except OpenRouterError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="OpenRouter", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + +class ConversationFlowHandler(ConfigSubentryFlow): + """Handle subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.options: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a sensor subentry.""" + if user_input is not None: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + return self.async_create_entry( + title=self.options[user_input[CONF_MODEL]], data=user_input + ) + entry = self._get_entry() + client = AsyncOpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=entry.data[CONF_API_KEY], + http_client=get_async_client(self.hass), + ) + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] + options = [] + async for model in client.with_options(timeout=10.0).models.list(): + options.append(SelectOptionDict(value=model.id, label=model.name)) # type: ignore[attr-defined] + self.options[model.id] = model.name # type: ignore[attr-defined] + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN, sort=True + ), + ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": RECOMMENDED_CONVERSATION_OPTIONS[ + CONF_PROMPT + ] + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ), + ) diff --git a/homeassistant/components/open_router/const.py b/homeassistant/components/open_router/const.py new file mode 100644 index 00000000000..9fbce10da4e --- /dev/null +++ b/homeassistant/components/open_router/const.py @@ -0,0 +1,18 @@ +"""Constants for the OpenRouter integration.""" + +import logging + +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + +DOMAIN = "open_router" +LOGGER = logging.getLogger(__package__) + +CONF_PROMPT = "prompt" +CONF_RECOMMENDED = "recommended" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py new file mode 100644 index 00000000000..06196565aad --- /dev/null +++ b/homeassistant/components/open_router/conversation.py @@ -0,0 +1,225 @@ +"""Conversation support for OpenRouter.""" + +from collections.abc import AsyncGenerator, Callable +import json +from typing import Any, Literal + +import openai +from openai import NOT_GIVEN +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessage, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionToolParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_MODEL, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OpenRouterConfigEntry +from .const import CONF_PROMPT, DOMAIN, LOGGER + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OpenRouterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [OpenRouterConversationEntity(config_entry, subentry)], + config_subentry_id=subentry_id, + ) + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition( + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the OpenRouter message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + ] + yield data + + +class OpenRouterConversationEntity(conversation.ConversationEntity): + """OpenRouter conversation agent.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: OpenRouterConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the agent.""" + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=DeviceEntryType.SERVICE, + ) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process a sentence.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + tools: list[ChatCompletionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + messages = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + client = self.entry.runtime_data + + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create( + model=self.model, + messages=messages, + tools=tools or NOT_GIVEN, + user=chat_log.conversation_id, + extra_headers={ + "X-Title": "Home Assistant", + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + }, + ) + except openai.OpenAIError as err: + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + result_message = result.choices[0].message + + messages.extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] + ) + if not chat_log.unresponded_tool_results: + break + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json new file mode 100644 index 00000000000..fab62e7971c --- /dev/null +++ b/homeassistant/components/open_router/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "open_router", + "name": "OpenRouter", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@joostlek"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/open_router", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["openai==1.93.3", "python-open-router==0.3.0"] +} diff --git a/homeassistant/components/open_router/quality_scale.yaml b/homeassistant/components/open_router/quality_scale.yaml new file mode 100644 index 00000000000..9b71a29dc6b --- /dev/null +++ b/homeassistant/components/open_router/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions are implemented + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions are implemented + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless conversation entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates state-less entities + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: + status: exempt + comment: no suitable device class for the conversation entity + entity-disabled-by-default: + status: exempt + comment: only one conversation entity + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json new file mode 100644 index 00000000000..6e6674dac06 --- /dev/null +++ b/homeassistant/components/open_router/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "An OpenRouter API key" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "config_subentries": { + "conversation": { + "step": { + "user": { + "description": "Configure the new conversation agent", + "data": { + "model": "Model", + "prompt": "Instructions", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" + }, + "data_description": { + "model": "The model to use for the conversation agent", + "prompt": "Instruct how the LLM should respond. This can be a template." + } + } + }, + "initiate_flow": { + "user": "Add conversation agent" + }, + "entry_type": "Conversation agent" + } + } +} diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index ce6872c7c20..aa1c967ca8f 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -42,6 +42,7 @@ from homeassistant.helpers.typing import VolDictType from .const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_PROMPT, CONF_REASONING_EFFORT, @@ -60,6 +61,7 @@ from .const import ( DOMAIN, RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CODE_INTERPRETER, RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, @@ -312,7 +314,12 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): options = self.options errors: dict[str, str] = {} - step_schema: VolDictType = {} + step_schema: VolDictType = { + vol.Optional( + CONF_CODE_INTERPRETER, + default=RECOMMENDED_CODE_INTERPRETER, + ): bool, + } model = options[CONF_CHAT_MODEL] @@ -375,18 +382,6 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): ) } - if not step_schema: - if self._is_new: - return self.async_create_entry( - title=options.pop(CONF_NAME), - data=options, - ) - return self.async_update_and_abort( - self._get_entry(), - self._get_reconfigure_subentry(), - data=options, - ) - if user_input is not None: if user_input.get(CONF_WEB_SEARCH): if user_input.get(CONF_WEB_SEARCH_USER_LOCATION): diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index a15f71118c0..cacef6fcff9 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -13,6 +13,7 @@ DEFAULT_AI_TASK_NAME = "OpenAI AI Task" DEFAULT_NAME = "OpenAI Conversation" CONF_CHAT_MODEL = "chat_model" +CONF_CODE_INTERPRETER = "code_interpreter" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" CONF_PROMPT = "prompt" @@ -27,6 +28,7 @@ CONF_WEB_SEARCH_CITY = "city" CONF_WEB_SEARCH_REGION = "region" CONF_WEB_SEARCH_COUNTRY = "country" CONF_WEB_SEARCH_TIMEZONE = "timezone" +RECOMMENDED_CODE_INTERPRETER = False RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" RECOMMENDED_MAX_TOKENS = 3000 RECOMMENDED_REASONING_EFFORT = "low" diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 25e89577ef3..803825c2810 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -6,7 +6,6 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry @@ -84,11 +83,4 @@ class OpenAIConversationEntity( await self._async_handle_chat_log(chat_log) - intent_response = intent.IntentResponse(language=user_input.language) - assert type(chat_log.content[-1]) is conversation.AssistantContent - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 7679bef83f1..93713c78d9c 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -38,6 +38,10 @@ from openai.types.responses import ( WebSearchToolParam, ) from openai.types.responses.response_input_param import FunctionCallOutput +from openai.types.responses.tool_param import ( + CodeInterpreter, + CodeInterpreterContainerCodeInterpreterToolAuto, +) from openai.types.responses.web_search_tool_param import UserLocation import voluptuous as vol from voluptuous_openapi import convert @@ -52,6 +56,7 @@ from homeassistant.util import slugify from .const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_REASONING_EFFORT, CONF_TEMPERATURE, @@ -292,7 +297,7 @@ class OpenAIBaseLLMEntity(Entity): """Generate an answer for the chat log.""" options = self.subentry.data - tools: list[ToolParam] | None = None + tools: list[ToolParam] = [] if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) @@ -314,10 +319,18 @@ class OpenAIBaseLLMEntity(Entity): country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), ) - if tools is None: - tools = [] tools.append(web_search) + if options.get(CONF_CODE_INTERPRETER): + tools.append( + CodeInterpreter( + type="code_interpreter", + container=CodeInterpreterContainerCodeInterpreterToolAuto( + type="auto" + ), + ) + ) + model_args = { "model": options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), "input": [], diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 5011fc9cf99..4446eff2c9e 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -28,7 +28,7 @@ "init": { "data": { "name": "[%key:common::config_flow::data::name%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "recommended": "Recommended model settings" }, @@ -48,12 +48,14 @@ "model": { "title": "Model-specific options", "data": { + "code_interpreter": "Enable code interpreter tool", "reasoning_effort": "Reasoning effort", "web_search": "Enable web search", "search_context_size": "Search context size", "user_location": "Include home location" }, "data_description": { + "code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions", "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", "web_search": "Allow the model to search the web for the latest information before generating a response", "search_context_size": "High level guidance for the amount of context window space to use for the search", diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 14203541359..f1d0113ac5e 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -50,16 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -async def async_reload_entry(hass: HomeAssistant, entry: PingConfigEntry) -> None: - """Handle an options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 27cb3f62bcd..d66f4beb8e5 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST from homeassistant.core import callback @@ -71,12 +71,12 @@ class PingConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Create the options flow.""" return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for Ping.""" async def async_step_init( diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index e5b98d00726..be0eae961e0 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -16,6 +16,7 @@ from .helpers import PlaystationNetwork PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.IMAGE, Platform.MEDIA_PLAYER, Platform.SENSOR, ] diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index debe7a338e2..f7f6143e94f 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -43,11 +43,14 @@ class PlaystationNetworkData: registered_platforms: set[PlatformType] = field(default_factory=set) trophy_summary: TrophySummary | None = None profile: dict[str, Any] = field(default_factory=dict) + shareable_profile_link: dict[str, str] = field(default_factory=dict) class PlaystationNetwork: """Helper Class to return playstation network data in an easy to use structure.""" + shareable_profile_link: dict[str, str] + def __init__(self, hass: HomeAssistant, npsso: str) -> None: """Initialize the class with the npsso token.""" rate = Rate(300, Duration.MINUTE * 15) @@ -63,6 +66,7 @@ class PlaystationNetwork: """Setup PSN.""" self.user = self.psn.user(online_id="me") self.client = self.psn.me() + self.shareable_profile_link = self.client.get_shareable_profile_link() self.trophy_titles = list(self.user.trophy_titles()) async def async_setup(self) -> None: @@ -100,7 +104,7 @@ class PlaystationNetwork: data = await self.hass.async_add_executor_job(self.retrieve_psn_data) data.username = self.user.online_id data.account_id = self.user.account_id - + data.shareable_profile_link = self.shareable_profile_link data.availability = data.presence["basicPresence"]["availability"] session = SessionData() diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index 2742ab1c989..2ea09823ca4 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -43,6 +43,14 @@ "offline": "mdi:account-off-outline" } } + }, + "image": { + "share_profile": { + "default": "mdi:share-variant" + }, + "avatar": { + "default": "mdi:account-circle" + } } } } diff --git a/homeassistant/components/playstation_network/image.py b/homeassistant/components/playstation_network/image.py new file mode 100644 index 00000000000..8f9d19e3a55 --- /dev/null +++ b/homeassistant/components/playstation_network/image.py @@ -0,0 +1,105 @@ +"""Image platform for PlayStation Network.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkUserDataCoordinator, +) +from .entity import PlaystationNetworkServiceEntity + +PARALLEL_UPDATES = 0 + + +class PlaystationNetworkImage(StrEnum): + """PlayStation Network images.""" + + AVATAR = "avatar" + SHARE_PROFILE = "share_profile" + + +@dataclass(kw_only=True, frozen=True) +class PlaystationNetworkImageEntityDescription(ImageEntityDescription): + """Image entity description.""" + + image_url_fn: Callable[[PlaystationNetworkData], str | None] + + +IMAGE_DESCRIPTIONS: tuple[PlaystationNetworkImageEntityDescription, ...] = ( + PlaystationNetworkImageEntityDescription( + key=PlaystationNetworkImage.SHARE_PROFILE, + translation_key=PlaystationNetworkImage.SHARE_PROFILE, + image_url_fn=lambda data: data.shareable_profile_link["shareImageUrl"], + ), + PlaystationNetworkImageEntityDescription( + key=PlaystationNetworkImage.AVATAR, + translation_key=PlaystationNetworkImage.AVATAR, + image_url_fn=( + lambda data: next( + ( + pic.get("url") + for pic in data.profile["avatars"] + if pic.get("size") == "xl" + ), + None, + ) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up image platform.""" + + coordinator = config_entry.runtime_data.user_data + + async_add_entities( + [ + PlaystationNetworkImageEntity(hass, coordinator, description) + for description in IMAGE_DESCRIPTIONS + ] + ) + + +class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity): + """An image entity.""" + + entity_description: PlaystationNetworkImageEntityDescription + + def __init__( + self, + hass: HomeAssistant, + coordinator: PlaystationNetworkUserDataCoordinator, + entity_description: PlaystationNetworkImageEntityDescription, + ) -> None: + """Initialize the image entity.""" + super().__init__(coordinator, entity_description) + ImageEntity.__init__(self, hass) + + self._attr_image_url = self.entity_description.image_url_fn(coordinator.data) + self._attr_image_last_updated = dt_util.utcnow() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + url = self.entity_description.image_url_fn(self.coordinator.data) + + if url != self._attr_image_url: + self._attr_image_url = url + self._cached_image = None + self._attr_image_last_updated = dt_util.utcnow() + + super()._handle_coordinator_update() diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index 0a9b8fe6162..bdbc2a5ddd4 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -125,8 +125,6 @@ class PsnMediaPlayerEntity( if session.title_id is not None else MediaPlayerState.ON ) - if session.status == "standby": - return MediaPlayerState.STANDBY return MediaPlayerState.OFF @property diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index 360687f97c8..aaefdf51506 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -96,6 +96,14 @@ "busy": "Away" } } + }, + "image": { + "share_profile": { + "name": "Share profile" + }, + "avatar": { + "name": "Avatar" + } } } } diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 2338464558d..4dc87554055 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -43,17 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True async def async_unload_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR]) - - -async def _async_update_listener( - hass: HomeAssistant, entry: ProximityConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py index 5818ec2979b..f60dcfae7b5 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_ZONE, UnitOfLength from homeassistant.core import State, callback @@ -87,7 +87,7 @@ class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> ProximityOptionsFlow: """Get the options flow for this handler.""" return ProximityOptionsFlow() @@ -118,7 +118,7 @@ class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): ) -class ProximityOptionsFlow(OptionsFlow): +class ProximityOptionsFlow(OptionsFlowWithReload): """Handle a option flow.""" def _user_form_schema(self, user_input: dict[str, Any]) -> vol.Schema: diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 78986b34351..0b7acdb1eb0 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -20,16 +20,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True -async def async_reload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None: - """Reload config entry.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool: """Unload config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 3ca7870b3cb..29139872913 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_API_KEY, @@ -312,7 +312,7 @@ class PurpleAirConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_by_coordinates() -class PurpleAirOptionsFlowHandler(OptionsFlow): +class PurpleAirOptionsFlowHandler(OptionsFlowWithReload): """Handle a PurpleAir options flow.""" def __init__(self) -> None: diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index f9cd751a81e..e986cc302ae 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -218,6 +218,9 @@ def _async_fix_device_id( for device_entry in device_entries: unique_id = str(next(iter(device_entry.identifiers))[1]) device_entry_map[unique_id] = device_entry + if unique_id.startswith(mac_address): + # Already in the correct format + continue if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: migrations[unique_id] = f"{mac_address}{suffix}" diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index 49731df5b6f..e8c54c94f84 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -240,8 +240,8 @@ "description": "Current weather condition code (WNUM)." }, "pressure": { - "name": "Barametric pressure", - "description": "Current barametric pressure (kPa)." + "name": "Barometric pressure", + "description": "Current barometric pressure (kPa)." }, "dewpoint": { "name": "Dew point", diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 3260bff44b5..236e1707461 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -243,10 +243,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload( - config_entry.add_update_listener(entry_update_listener) - ) - return True @@ -295,13 +291,6 @@ async def register_callbacks( ) -async def entry_update_listener( - hass: HomeAssistant, config_entry: ReolinkConfigEntry -) -> None: - """Update the configuration of the host entity.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index eee8b04dfcc..2ac51792c3f 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -61,7 +61,7 @@ DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL} API_STARTUP_TIME = 5 -class ReolinkOptionsFlowHandler(OptionsFlow): +class ReolinkOptionsFlowHandler(OptionsFlowWithReload): """Handle Reolink options.""" async def async_step_init( diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index cf3079e51e8..875af48e47c 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -402,7 +402,12 @@ "default": "mdi:thermometer" }, "battery_state": { - "default": "mdi:battery-charging" + "default": "mdi:battery-unknown", + "state": { + "discharging": "mdi:battery-minus-variant", + "charging": "mdi:battery-charging", + "chargecomplete": "mdi:battery-check" + } }, "day_night_state": { "default": "mdi:theme-light-dark" diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 9df10197a1a..3db44b0e5d2 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -13,9 +13,7 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorDeviceClass, ) -from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, @@ -181,18 +179,6 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): self.entity_id, variables, None ) - if value is None or self.device_class not in ( - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - ): - self._attr_native_value = value - self._process_manual_data(variables) - self.async_write_ha_state() - return - - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) - + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 6697779adf6..bc10ab7309c 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -43,8 +43,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: """Set up roborock from a config entry.""" - entry.async_on_unload(entry.add_update_listener(update_listener)) - user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) api_client = RoborockApiClient( entry.data[CONF_USERNAME], @@ -336,12 +334,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: - """Handle options update.""" - # Reload entry to update data - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: """Handle removal of an entry.""" await async_remove_map_storage(hass, entry.entry_id) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 62943e0dcc9..6a35bf79233 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback @@ -124,14 +124,9 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch(reason="wrong_account") reauth_entry = self._get_reauth_entry() - self.hass.config_entries.async_update_entry( - reauth_entry, - data={ - **reauth_entry.data, - CONF_USER_DATA: user_data.as_dict(), - }, + return self.async_update_reload_and_abort( + reauth_entry, data_updates={CONF_USER_DATA: user_data.as_dict()} ) - return self.async_abort(reason="reauth_successful") self._abort_if_unique_id_configured(error="already_configured_account") return self._create_entry(self._client, self._username, user_data) @@ -202,7 +197,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): return RoborockOptionsFlowHandler(config_entry) -class RoborockOptionsFlowHandler(OptionsFlow): +class RoborockOptionsFlowHandler(OptionsFlowWithReload): """Handle an option flow for Roborock.""" def __init__(self, config_entry: RoborockConfigEntry) -> None: diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index be0b20c97fb..46149264e55 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -25,16 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True async def async_unload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_reload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 47bc86802d2..b28648589c9 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback @@ -202,7 +202,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return RokuOptionsFlowHandler() -class RokuOptionsFlowHandler(OptionsFlow): +class RokuOptionsFlowHandler(OptionsFlowWithReload): """Handle Roku options.""" async def async_step_init( diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index a2ab8e6e466..1b927757a39 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==3.1.0", - "async-upnp-client==0.44.0" + "async-upnp-client==0.45.0" ], "ssdp": [ { diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 893c30dfd41..b71afe01e56 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2025.4.0"] + "requirements": ["pyschlage==2025.7.3"] } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 80d53a2c8b1..3e7f416166b 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -7,8 +7,7 @@ from typing import Any, cast import voluptuous as vol -from homeassistant.components.sensor import CONF_STATE_CLASS, SensorDeviceClass -from homeassistant.components.sensor.helpers import async_parse_date_datetime +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( CONF_ATTRIBUTE, CONF_DEVICE_CLASS, @@ -218,17 +217,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti self.entity_id, variables, None ) - if self.device_class not in { - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - }: - self._attr_native_value = value - self._process_manual_data(variables) - return - - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) @property diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index fa434588b34..9291d7aa70f 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -163,7 +163,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @property def sleep_period(self) -> int: """Sleep period of the device.""" - return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) + return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) # type: ignore[no-any-return] def async_setup(self, pending_platforms: list[Platform] | None = None) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 953fcbace06..1af365debfb 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -451,7 +451,7 @@ def get_rpc_entity_name( def get_device_entry_gen(entry: ConfigEntry) -> int: """Return the device generation from config entry.""" - return entry.data.get(CONF_GEN, 1) + return entry.data.get(CONF_GEN, 1) # type: ignore[no-any-return] def get_rpc_key_instances( diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 4243684cd52..7a9415bac20 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -4,6 +4,8 @@ DATA_SLEEPIQ = "data_sleepiq" DOMAIN = "sleepiq" ACTUATOR = "actuator" +CORE_CLIMATE_TIMER = "core_climate_timer" +CORE_CLIMATE = "core_climate" BED = "bed" FIRMNESS = "firmness" ICON_EMPTY = "mdi:bed-empty" @@ -15,6 +17,8 @@ FOOT_WARMING_TIMER = "foot_warming_timer" FOOT_WARMER = "foot_warmer" ENTITY_TYPES = { ACTUATOR: "Position", + CORE_CLIMATE_TIMER: "Core Climate Timer", + CORE_CLIMATE: "Core Climate", FIRMNESS: "Firmness", PRESSURE: "Pressure", IS_IN_BED: "Is In Bed", diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 53d6c366e46..ffbcbe7a970 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -7,20 +7,28 @@ from dataclasses import dataclass from typing import Any, cast from asyncsleepiq import ( + CoreTemps, FootWarmingTemps, SleepIQActuator, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQSleeper, ) -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ACTUATOR, + CORE_CLIMATE_TIMER, DOMAIN, ENTITY_TYPES, FIRMNESS, @@ -95,6 +103,27 @@ def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) return f"{bed.id}_{foot_warmer.side.value}_{FOOT_WARMING_TIMER}" +async def _async_set_core_climate_time( + core_climate: SleepIQCoreClimate, time: int +) -> None: + temperature = CoreTemps(core_climate.temperature) + if temperature != CoreTemps.OFF: + await core_climate.turn_on(temperature, time) + + core_climate.timer = time + + +def _get_core_climate_name(bed: SleepIQBed, core_climate: SleepIQCoreClimate) -> str: + sleeper = sleeper_for_side(bed, core_climate.side) + return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[CORE_CLIMATE_TIMER]}" + + +def _get_core_climate_unique_id( + bed: SleepIQBed, core_climate: SleepIQCoreClimate +) -> str: + return f"{bed.id}_{core_climate.side.value}_{CORE_CLIMATE_TIMER}" + + NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { FIRMNESS: SleepIQNumberEntityDescription( key=FIRMNESS, @@ -132,6 +161,20 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { get_name_fn=_get_foot_warming_name, get_unique_id_fn=_get_foot_warming_unique_id, ), + CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription( + key=CORE_CLIMATE_TIMER, + native_min_value=0, + native_max_value=600, + native_step=30, + name=ENTITY_TYPES[CORE_CLIMATE_TIMER], + icon="mdi:timer", + value_fn=lambda core_climate: core_climate.timer, + set_value_fn=_async_set_core_climate_time, + get_name_fn=_get_core_climate_name, + get_unique_id_fn=_get_core_climate_unique_id, + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + ), } @@ -172,6 +215,15 @@ async def async_setup_entry( ) for foot_warmer in bed.foundation.foot_warmers ) + entities.extend( + SleepIQNumberEntity( + data.data_coordinator, + bed, + core_climate, + NUMBER_DESCRIPTIONS[CORE_CLIMATE_TIMER], + ) + for core_climate in bed.foundation.core_climates + ) async_add_entities(entities) diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 7d059ba6b59..d4bc9fda3a4 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -3,9 +3,11 @@ from __future__ import annotations from asyncsleepiq import ( + CoreTemps, FootWarmingTemps, Side, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQPreset, ) @@ -15,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, FOOT_WARMER +from .const import CORE_CLIMATE, DOMAIN, FOOT_WARMER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side @@ -37,6 +39,10 @@ async def async_setup_entry( SleepIQFootWarmingTempSelectEntity(data.data_coordinator, bed, foot_warmer) for foot_warmer in bed.foundation.foot_warmers ) + entities.extend( + SleepIQCoreTempSelectEntity(data.data_coordinator, bed, core_climate) + for core_climate in bed.foundation.core_climates + ) async_add_entities(entities) @@ -115,3 +121,57 @@ class SleepIQFootWarmingTempSelectEntity( self._attr_current_option = option await self.coordinator.async_request_refresh() self.async_write_ha_state() + + +class SleepIQCoreTempSelectEntity( + SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SelectEntity +): + """Representation of a SleepIQ core climate temperature select entity.""" + + # Maps to translate between asyncsleepiq and HA's naming preference + SLEEPIQ_TO_HA_CORE_TEMP_MAP = { + CoreTemps.OFF: "off", + CoreTemps.HEATING_PUSH_LOW: "heating_low", + CoreTemps.HEATING_PUSH_MED: "heating_medium", + CoreTemps.HEATING_PUSH_HIGH: "heating_high", + CoreTemps.COOLING_PULL_LOW: "cooling_low", + CoreTemps.COOLING_PULL_MED: "cooling_medium", + CoreTemps.COOLING_PULL_HIGH: "cooling_high", + } + HA_TO_SLEEPIQ_CORE_TEMP_MAP = {v: k for k, v in SLEEPIQ_TO_HA_CORE_TEMP_MAP.items()} + + _attr_icon = "mdi:heat-wave" + _attr_options = list(SLEEPIQ_TO_HA_CORE_TEMP_MAP.values()) + _attr_translation_key = "core_temps" + + def __init__( + self, + coordinator: SleepIQDataUpdateCoordinator, + bed: SleepIQBed, + core_climate: SleepIQCoreClimate, + ) -> None: + """Initialize the select entity.""" + self.core_climate = core_climate + sleeper = sleeper_for_side(bed, core_climate.side) + super().__init__(coordinator, bed, sleeper, CORE_CLIMATE) + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + sleepiq_option = CoreTemps(self.core_climate.temperature) + self._attr_current_option = self.SLEEPIQ_TO_HA_CORE_TEMP_MAP[sleepiq_option] + + async def async_select_option(self, option: str) -> None: + """Change the current preset.""" + temperature = self.HA_TO_SLEEPIQ_CORE_TEMP_MAP[option] + timer = self.core_climate.timer or 240 + + if temperature == CoreTemps.OFF: + await self.core_climate.turn_off() + else: + await self.core_climate.turn_on(temperature, timer) + + self._attr_current_option = option + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 634202d6da8..58a35ea914b 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -33,6 +33,17 @@ "medium": "[%key:common::state::medium%]", "high": "[%key:common::state::high%]" } + }, + "core_temps": { + "state": { + "off": "[%key:common::state::off%]", + "heating_low": "Heating low", + "heating_medium": "Heating medium", + "heating_high": "Heating high", + "cooling_low": "Cooling low", + "cooling_medium": "Cooling medium", + "cooling_high": "Cooling high" + } } } } diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py index 4690fe8016c..7d2027a985a 100644 --- a/homeassistant/components/slide_local/__init__.py +++ b/homeassistant/components/slide_local/__init__.py @@ -21,16 +21,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> boo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True -async def update_listener(hass: HomeAssistant, entry: SlideConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index 96aac1a135c..7593d502bec 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -14,7 +14,11 @@ from goslideapi.goslideapi import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -232,7 +236,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): ) -class SlideOptionsFlowHandler(OptionsFlow): +class SlideOptionsFlowHandler(OptionsFlowWithReload): """Handle a options flow for slide_local.""" async def async_step_init( diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json index 8f7786bdf72..e2e9e08dcab 100644 --- a/homeassistant/components/smarla/manifest.json +++ b/homeassistant/components/smarla/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["pysmarlaapi", "pysignalr"], "quality_scale": "bronze", - "requirements": ["pysmarlaapi==0.9.0"] + "requirements": ["pysmarlaapi==0.9.1"] } diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 1869b333071..085cbdcbbce 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator -PLATFORMS = [Platform.WEATHER] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py index 511ba8b38d9..ba7542694df 100644 --- a/homeassistant/components/smhi/coordinator.py +++ b/homeassistant/components/smhi/coordinator.py @@ -61,3 +61,8 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): daily=_forecast_daily, hourly=_forecast_hourly, ) + + @property + def current(self) -> SMHIForecast: + """Return the current metrics.""" + return self.data.daily[0] diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py index 89dca3360ca..fb565a7fc51 100644 --- a/homeassistant/components/smhi/entity.py +++ b/homeassistant/components/smhi/entity.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import abstractmethod +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -16,7 +17,6 @@ class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): _attr_attribution = "Swedish weather institute (SMHI)" _attr_has_entity_name = True - _attr_name = None def __init__( self, @@ -36,6 +36,12 @@ class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): ) self.update_entity_data() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_entity_data() + super()._handle_coordinator_update() + @abstractmethod def update_entity_data(self) -> None: """Refresh the entity data.""" diff --git a/homeassistant/components/smhi/icons.json b/homeassistant/components/smhi/icons.json new file mode 100644 index 00000000000..5c62b8f03b4 --- /dev/null +++ b/homeassistant/components/smhi/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "thunder": { + "default": "mdi:lightning-bolt" + }, + "total_cloud": { + "default": "mdi:cloud" + }, + "low_cloud": { + "default": "mdi:cloud-arrow-down" + }, + "medium_cloud": { + "default": "mdi:cloud-arrow-right" + }, + "high_cloud": { + "default": "mdi:cloud-arrow-up" + }, + "precipitation_category": { + "default": "mdi:weather-pouring" + }, + "frozen_precipitation": { + "default": "mdi:weather-snowy-rainy" + } + } + } +} diff --git a/homeassistant/components/smhi/sensor.py b/homeassistant/components/smhi/sensor.py new file mode 100644 index 00000000000..bba207c0f09 --- /dev/null +++ b/homeassistant/components/smhi/sensor.py @@ -0,0 +1,139 @@ +"""Sensor platform for SMHI integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator +from .entity import SmhiWeatherBaseEntity + +PARALLEL_UPDATES = 0 + + +def get_percentage_values(entity: SMHISensor, key: str) -> int | None: + """Return percentage values in correct range.""" + value: int | None = entity.coordinator.current.get(key) # type: ignore[assignment] + if value is not None and 0 <= value <= 100: + return value + if value is not None: + return 0 + return None + + +@dataclass(frozen=True, kw_only=True) +class SMHISensorEntityDescription(SensorEntityDescription): + """Describes SMHI sensor entity.""" + + value_fn: Callable[[SMHISensor], StateType | datetime] + + +SENSOR_DESCRIPTIONS: tuple[SMHISensorEntityDescription, ...] = ( + SMHISensorEntityDescription( + key="thunder", + translation_key="thunder", + value_fn=lambda entity: get_percentage_values(entity, "thunder"), + native_unit_of_measurement=PERCENTAGE, + ), + SMHISensorEntityDescription( + key="total_cloud", + translation_key="total_cloud", + value_fn=lambda entity: get_percentage_values(entity, "total_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="low_cloud", + translation_key="low_cloud", + value_fn=lambda entity: get_percentage_values(entity, "low_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="medium_cloud", + translation_key="medium_cloud", + value_fn=lambda entity: get_percentage_values(entity, "medium_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="high_cloud", + translation_key="high_cloud", + value_fn=lambda entity: get_percentage_values(entity, "high_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="precipitation_category", + translation_key="precipitation_category", + value_fn=lambda entity: str( + get_percentage_values(entity, "precipitation_category") + ), + device_class=SensorDeviceClass.ENUM, + options=["0", "1", "2", "3", "4", "5", "6"], + ), + SMHISensorEntityDescription( + key="frozen_precipitation", + translation_key="frozen_precipitation", + value_fn=lambda entity: get_percentage_values(entity, "frozen_precipitation"), + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SMHIConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SMHI sensor platform.""" + + coordinator = entry.runtime_data + location = entry.data + async_add_entities( + SMHISensor( + location[CONF_LOCATION][CONF_LATITUDE], + location[CONF_LOCATION][CONF_LONGITUDE], + coordinator=coordinator, + entity_description=description, + ) + for description in SENSOR_DESCRIPTIONS + ) + + +class SMHISensor(SmhiWeatherBaseEntity, SensorEntity): + """Representation of a SMHI Sensor.""" + + entity_description: SMHISensorEntityDescription + + def __init__( + self, + latitude: str, + longitude: str, + coordinator: SMHIDataUpdateCoordinator, + entity_description: SMHISensorEntityDescription, + ) -> None: + """Initiate SMHI Sensor.""" + self.entity_description = entity_description + super().__init__( + latitude, + longitude, + coordinator, + ) + self._attr_unique_id = f"{latitude}, {longitude}-{entity_description.key}" + + def update_entity_data(self) -> None: + """Refresh the entity data.""" + if self.coordinator.data.daily: + self._attr_native_value = self.entity_description.value_fn(self) diff --git a/homeassistant/components/smhi/strings.json b/homeassistant/components/smhi/strings.json index 3d2a790e6b6..b6c8f2049fe 100644 --- a/homeassistant/components/smhi/strings.json +++ b/homeassistant/components/smhi/strings.json @@ -23,5 +23,39 @@ "error": { "wrong_location": "Location Sweden only" } + }, + "entity": { + "sensor": { + "thunder": { + "name": "Thunder probability" + }, + "total_cloud": { + "name": "Total cloud coverage" + }, + "low_cloud": { + "name": "Low cloud coverage" + }, + "medium_cloud": { + "name": "Medium cloud coverage" + }, + "high_cloud": { + "name": "High cloud coverage" + }, + "precipitation_category": { + "name": "Precipitation category", + "state": { + "0": "No precipitation", + "1": "Snow", + "2": "Snow and rain", + "3": "Rain", + "4": "Drizzle", + "5": "Freezing rain", + "6": "Freezing drizzle" + } + }, + "frozen_precipitation": { + "name": "Frozen precipitation" + } + } } } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 5faef04e03d..ccfff7cc2e5 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -111,6 +111,7 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): _attr_supported_features = ( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) + _attr_name = None def update_entity_data(self) -> None: """Refresh the entity data.""" diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 3574affaccd..46e0dc83050 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -217,7 +217,7 @@ class SnmpSensor(ManualTriggerSensorEntity): self.entity_id, variables, STATE_UNKNOWN ) - self._attr_native_value = value + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index 89796f5ce46..fdbaaf9f427 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -11,8 +11,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_SYSTEM_ID, DATA_SOMFY_MYLINK, DOMAIN, MYLINK_STATUS, PLATFORMS -UNDO_UPDATE_LISTENER = "undo_update_listener" - _LOGGER = logging.getLogger(__name__) @@ -44,12 +42,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if "result" not in mylink_status: raise ConfigEntryNotReady("The Somfy MyLink device returned an empty result") - undo_listener = entry.add_update_listener(_async_update_listener) - hass.data[DOMAIN][entry.entry_id] = { DATA_SOMFY_MYLINK: somfy_mylink, MYLINK_STATUS: mylink_status, - UNDO_UPDATE_LISTENER: undo_listener, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -57,18 +52,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index a806d581aec..91cfae87347 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback @@ -125,7 +125,7 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for somfy_mylink.""" def __init__(self, config_entry: ConfigEntry) -> None: diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 960227ff0da..1c786356486 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -65,7 +65,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host_configuration=host_configuration, session=async_get_clientsession(hass), ) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = { "upcoming": CalendarDataUpdateCoordinator( hass, entry, host_configuration, sonarr @@ -126,8 +125,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index e1cedba10e7..278d3fbd7bb 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback @@ -152,7 +152,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): return data_schema -class SonarrOptionsFlowHandler(OptionsFlow): +class SonarrOptionsFlowHandler(OptionsFlowWithReload): """Handle Sonarr client options.""" async def async_step_init( diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 8824c56a762..c1e1b4f80df 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -72,7 +72,7 @@ class SonosFavorites(SonosHouseholdCoordinator): """Process the event payload in an async lock and update entities.""" event_id = event.variables["favorites_update_id"] container_ids = event.variables["container_update_i_ds"] - if not (match := re.search(r"FV:2,(\d+)", container_ids)): + if not container_ids or not (match := re.search(r"FV:2,(\d+)", container_ids)): return container_id = int(match.group(1)) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index e4f439013c6..5f66ba380fe 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -42,7 +42,6 @@ async def async_setup_entry( async_at_started(hass, _async_finish_startup) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) return True @@ -52,10 +51,3 @@ async def async_unload_entry( ) -> bool: """Unload SpeedTest Entry from config_entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, config_entry: SpeedTestConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index 4fbca5e0d29..4bae503f85e 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -6,7 +6,11 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.core import callback from .const import ( @@ -45,7 +49,7 @@ class SpeedTestFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=DEFAULT_NAME, data=user_input) -class SpeedTestOptionsFlowHandler(OptionsFlow): +class SpeedTestOptionsFlowHandler(OptionsFlowWithReload): """Handle SpeedTest options.""" def __init__(self) -> None: diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index e3e6c699d03..33ed64be2bf 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -87,11 +87,6 @@ def remove_configured_db_url_if_not_needed( ) -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener for options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up SQL from yaml config.""" if (conf := config.get(DOMAIN)) is None: @@ -115,8 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: remove_configured_db_url_if_not_needed(hass, entry) - entry.async_on_unload(entry.add_update_listener(async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 4fe04f2401c..37a6f9ef104 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -209,7 +209,7 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN): ) -class SQLOptionsFlowHandler(OptionsFlow): +class SQLOptionsFlowHandler(OptionsFlowWithReload): """Handle SQL options.""" async def async_step_init( diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index b86a33db7ab..8c0ba81d6d2 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -401,9 +401,10 @@ class SQLSensor(ManualTriggerSensorEntity): if data is not None and self._template is not None: variables = self._template_variables_with_value(data) if self._render_availability_template(variables): - self._attr_native_value = self._template.async_render_as_value_template( + _value = self._template.async_render_as_value_template( self.entity_id, variables, None ) + self._set_native_value_with_possible_timestamp(_value) self._process_manual_data(variables) else: self._attr_native_value = data diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index c6cb04b5ffb..2bd845923fc 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -112,9 +112,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - if not status: # pysqueezebox's async_query returns None on various issues, # including HTTP errors where it sets lms.http_status. - http_status = getattr(lms, "http_status", "N/A") - if http_status == HTTPStatus.UNAUTHORIZED: + if lms.http_status == HTTPStatus.UNAUTHORIZED: _LOGGER.warning("Authentication failed for Squeezebox server %s", host) raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -128,14 +127,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - _LOGGER.warning( "LMS %s returned no status or an error (HTTP status: %s). Retrying setup", host, - http_status, + lms.http_status, ) raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="init_get_status_failed", translation_placeholders={ "host": str(host), - "http_status": str(http_status), + "http_status": str(lms.http_status), }, ) diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py index 1045e526ee3..ea305d71f99 100644 --- a/homeassistant/components/squeezebox/binary_sensor.py +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -49,7 +49,7 @@ async def async_setup_entry( class ServerStatusBinarySensor(LMSStatusEntity, BinarySensorEntity): - """LMS Status based sensor from LMS via cooridnatior.""" + """LMS Status based sensor from LMS via coordinator.""" @property def is_on(self) -> bool: diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 6582f143e79..9508420ec5f 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -30,7 +30,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): +class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """LMS Status custom coordinator.""" config_entry: SqueezeboxConfigEntry @@ -59,13 +59,13 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): else: _LOGGER.warning("Can't query server capabilities %s", self.lms.name) - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from LMS status call. Then we process only a subset to make then nice for HA """ async with timeout(STATUS_API_TIMEOUT): - data: dict | None = await self.lms.async_prepared_status() + data: dict[str, Any] | None = await self.lms.async_prepared_status() if not data: raise UpdateFailed( @@ -111,7 +111,7 @@ class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Only update players available at last update, unavailable players are rediscovered instead await self.player.async_update() - if self.player.connected is False: + if not self.player.connected: _LOGGER.info("Player %s is not available", self.name) self.available = False diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index f37faa4e115..0dbc1b96b0c 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -226,10 +226,7 @@ def get_announce_timeout(extra: dict) -> int | None: class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): - """Representation of the media player features of a SqueezeBox device. - - Wraps a pysqueezebox.Player() object. - """ + """Representation of the media player features of a SqueezeBox device.""" _attr_supported_features = ( MediaPlayerEntityFeature.BROWSE_MEDIA @@ -286,9 +283,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): @property def browse_limit(self) -> int: - """Return the step to be used for volume up down.""" - return self.coordinator.config_entry.options.get( - CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + """Return the max number of items to return from browse.""" + return int( + self.coordinator.config_entry.options.get( + CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + ) ) @property diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index 11c169910dc..79390910ef7 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -88,7 +88,7 @@ async def async_setup_entry( class ServerStatusSensor(LMSStatusEntity, SensorEntity): - """LMS Status based sensor from LMS via cooridnatior.""" + """LMS Status based sensor from LMS via coordinator.""" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 93943b0a9ea..2471e45b4e0 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.44.0"] + "requirements": ["async-upnp-client==0.45.0"] } diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index f800c82f1f9..34799e366d1 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -1,5 +1,7 @@ """The statistics component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -7,15 +9,21 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) DOMAIN = "statistics" PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Statistics from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -36,6 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -52,6 +61,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the statistics config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Statistics config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index fb8c09868d5..d9ff172e0a4 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -161,6 +161,8 @@ OPTIONS_FLOW = { class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for Statistics.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -234,15 +236,15 @@ async def ws_start_preview( ) preview_entity = StatisticsSensor( hass, - entity_id, - name, - None, - state_characteristic, - sampling_size, - max_age, - msg["user_input"].get(CONF_KEEP_LAST_SAMPLE), - msg["user_input"].get(CONF_PRECISION), - msg["user_input"].get(CONF_PERCENTILE), + source_entity_id=entity_id, + name=name, + unique_id=None, + state_characteristic=state_characteristic, + samples_max_buffer_size=sampling_size, + samples_max_age=max_age, + samples_keep_last=msg["user_input"].get(CONF_KEEP_LAST_SAMPLE), + precision=msg["user_input"].get(CONF_PRECISION), + percentile=msg["user_input"].get(CONF_PERCENTILE), ) preview_entity.hass = hass diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index a5c5f10ecd0..14471ab16ee 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -46,7 +46,7 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -659,6 +659,7 @@ class StatisticsSensor(SensorEntity): def __init__( self, hass: HomeAssistant, + *, source_entity_id: str, name: str, unique_id: str | None, @@ -673,10 +674,11 @@ class StatisticsSensor(SensorEntity): self._attr_name: str = name self._attr_unique_id: str | None = unique_id self._source_entity_id: str = source_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) + if source_entity_id: # Guard against empty source_entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + source_entity_id, + ) self.is_binary: bool = ( split_entity_id(self._source_entity_id)[0] == BINARY_SENSOR_DOMAIN ) @@ -725,12 +727,11 @@ class StatisticsSensor(SensorEntity): def _async_handle_new_state( self, - reported_state: State | None, + reported_state: State, + timestamp: float, ) -> None: """Handle the sensor state changes.""" - if (new_state := reported_state) is None: - return - self._add_state_to_queue(new_state) + self._add_state_to_queue(reported_state, timestamp) self._async_purge_update_and_schedule() if self._preview_callback: @@ -745,14 +746,18 @@ class StatisticsSensor(SensorEntity): self, event: Event[EventStateChangedData], ) -> None: - self._async_handle_new_state(event.data["new_state"]) + if (new_state := event.data["new_state"]) is None: + return + self._async_handle_new_state(new_state, new_state.last_updated_timestamp) @callback def _async_stats_sensor_state_report_listener( self, event: Event[EventStateReportedData], ) -> None: - self._async_handle_new_state(event.data["new_state"]) + self._async_handle_new_state( + event.data["new_state"], event.data["last_reported"].timestamp() + ) async def _async_stats_sensor_startup(self) -> None: """Add listener and get recorded state. @@ -783,7 +788,9 @@ class StatisticsSensor(SensorEntity): """Register callbacks.""" await self._async_stats_sensor_startup() - def _add_state_to_queue(self, new_state: State) -> None: + def _add_state_to_queue( + self, new_state: State, last_reported_timestamp: float + ) -> None: """Add the state to the queue.""" # Attention: it is not safe to store the new_state object, @@ -803,7 +810,7 @@ class StatisticsSensor(SensorEntity): self.states.append(new_state.state == "on") else: self.states.append(float(new_state.state)) - self.ages.append(new_state.last_reported_timestamp) + self.ages.append(last_reported_timestamp) self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True except ValueError: self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False @@ -1060,7 +1067,7 @@ class StatisticsSensor(SensorEntity): self._fetch_states_from_database ): for state in reversed(states): - self._add_state_to_queue(state) + self._add_state_to_queue(state, state.last_reported_timestamp) self._calculate_state_attributes(state) self._async_purge_update_and_schedule() diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 5ef7eec9976..22168c21f97 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.68.1"] + "requirements": ["PySwitchbot==0.68.2"] } diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py index 0b96b354436..27239a5a520 100644 --- a/homeassistant/components/syncthru/coordinator.py +++ b/homeassistant/components/syncthru/coordinator.py @@ -28,6 +28,7 @@ class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]): hass, _LOGGER, name=DOMAIN, + config_entry=entry, update_interval=timedelta(seconds=30), ) self.syncthru = SyncThru( diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index e568ce5a6d1..7146d42136e 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -136,7 +136,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) coordinator_switches=coordinator_switches, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) if entry.options[CONF_BACKUP_SHARE]: @@ -172,13 +171,6 @@ async def async_unload_entry( return unload_ok -async def _async_update_listener( - hass: HomeAssistant, entry: SynologyDSMConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_config_entry_device( hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index f0da6f8fe47..6e3469970d1 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_DISKS, @@ -441,7 +441,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return None -class SynologyDSMOptionsFlowHandler(OptionsFlow): +class SynologyDSMOptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow.""" config_entry: SynologyDSMConfigEntry diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 0513d63b893..df33845437f 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -41,6 +41,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.DEVICE_TRACKER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.WATER_HEATER, diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 09c6ec40208..79486ff998b 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -73,6 +73,8 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): "weather": {}, "geofence": {}, "zone": {}, + "zone_control": {}, + "heating_circuits": {}, } @property @@ -99,11 +101,14 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.home_name = tado_home["name"] devices = await self._async_update_devices() - zones = await self._async_update_zones() + zones, zone_controls = await self._async_update_zones() home = await self._async_update_home() + heating_circuits = await self._async_update_heating_circuits() self.data["device"] = devices self.data["zone"] = zones + self.data["zone_control"] = zone_controls + self.data["heating_circuits"] = heating_circuits self.data["weather"] = home["weather"] self.data["geofence"] = home["geofence"] @@ -166,7 +171,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): return mapped_devices - async def _async_update_zones(self) -> dict[int, dict]: + async def _async_update_zones(self) -> tuple[dict[int, dict], dict[int, dict]]: """Update the zone data from Tado.""" try: @@ -179,10 +184,12 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): raise UpdateFailed(f"Error updating Tado zones: {err}") from err mapped_zones: dict[int, dict] = {} + mapped_zone_controls: dict[int, dict] = {} for zone in zone_states: mapped_zones[int(zone)] = await self._update_zone(int(zone)) + mapped_zone_controls[int(zone)] = await self._update_zone_control(int(zone)) - return mapped_zones + return mapped_zones, mapped_zone_controls async def _update_zone(self, zone_id: int) -> dict[str, str]: """Update the internal data of a zone.""" @@ -199,6 +206,24 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): _LOGGER.debug("Zone %s updated, with data: %s", zone_id, data) return data + async def _update_zone_control(self, zone_id: int) -> dict[str, Any]: + """Update the internal zone control data of a zone.""" + + _LOGGER.debug("Updating zone control for zone %s", zone_id) + try: + zone_control_data = await self.hass.async_add_executor_job( + self._tado.get_zone_control, zone_id + ) + except RequestException as err: + _LOGGER.error( + "Error updating Tado zone control for zone %s: %s", zone_id, err + ) + raise UpdateFailed( + f"Error updating Tado zone control for zone {zone_id}: {err}" + ) from err + + return zone_control_data + async def _async_update_home(self) -> dict[str, dict]: """Update the home data from Tado.""" @@ -217,6 +242,23 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): return {"weather": weather, "geofence": geofence} + async def _async_update_heating_circuits(self) -> dict[str, dict]: + """Update the heating circuits data from Tado.""" + + try: + heating_circuits = await self.hass.async_add_executor_job( + self._tado.get_heating_circuits + ) + except RequestException as err: + _LOGGER.error("Error updating Tado heating circuits: %s", err) + raise UpdateFailed(f"Error updating Tado heating circuits: {err}") from err + + mapped_heating_circuits: dict[str, dict] = {} + for circuit in heating_circuits: + mapped_heating_circuits[circuit["driverShortSerialNo"]] = circuit + + return mapped_heating_circuits + async def get_capabilities(self, zone_id: int | str) -> dict: """Fetch the capabilities from Tado.""" @@ -364,6 +406,20 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): except RequestException as exc: raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc + async def set_heating_circuit(self, zone_id: int, circuit_id: int | None) -> None: + """Set heating circuit for zone.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_zone_heating_circuit, + zone_id, + circuit_id, + ) + except RequestException as exc: + raise HomeAssistantError( + f"Error setting Tado heating circuit: {exc}" + ) from exc + await self._update_zone_control(zone_id) + class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage the mobile devices from Tado via PyTado.""" diff --git a/homeassistant/components/tado/select.py b/homeassistant/components/tado/select.py new file mode 100644 index 00000000000..6db765128c2 --- /dev/null +++ b/homeassistant/components/tado/select.py @@ -0,0 +1,108 @@ +"""Module for Tado select entities.""" + +import logging + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TadoConfigEntry +from .entity import TadoDataUpdateCoordinator, TadoZoneEntity + +_LOGGER = logging.getLogger(__name__) + +NO_HEATING_CIRCUIT_OPTION = "no_heating_circuit" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Tado select platform.""" + + tado = entry.runtime_data.coordinator + entities: list[SelectEntity] = [ + TadoHeatingCircuitSelectEntity(tado, zone["name"], zone["id"]) + for zone in tado.zones + if zone["type"] == "HEATING" + ] + + async_add_entities(entities, True) + + +class TadoHeatingCircuitSelectEntity(TadoZoneEntity, SelectEntity): + """Representation of a Tado heating circuit select entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_has_entity_name = True + _attr_icon = "mdi:water-boiler" + _attr_translation_key = "heating_circuit" + + def __init__( + self, + coordinator: TadoDataUpdateCoordinator, + zone_name: str, + zone_id: int, + ) -> None: + """Initialize the Tado heating circuit select entity.""" + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) + + self._attr_unique_id = f"{zone_id} {coordinator.home_id} heating_circuit" + + self._attr_options = [] + self._attr_current_option = None + + async def async_select_option(self, option: str) -> None: + """Update the selected heating circuit.""" + heating_circuit_id = ( + None + if option == NO_HEATING_CIRCUIT_OPTION + else self.coordinator.data["heating_circuits"].get(option, {}).get("number") + ) + await self.coordinator.set_heating_circuit(self.zone_id, heating_circuit_id) + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_callback() + super()._handle_coordinator_update() + + @callback + def _async_update_callback(self) -> None: + """Handle update callbacks.""" + # Heating circuits list + heating_circuits = self.coordinator.data["heating_circuits"].values() + self._attr_options = [NO_HEATING_CIRCUIT_OPTION] + self._attr_options.extend(hc["driverShortSerialNo"] for hc in heating_circuits) + + # Current heating circuit + zone_control = self.coordinator.data["zone_control"].get(self.zone_id) + if zone_control and "heatingCircuit" in zone_control: + heating_circuit_number = zone_control["heatingCircuit"] + if heating_circuit_number is None: + self._attr_current_option = NO_HEATING_CIRCUIT_OPTION + else: + # Find heating circuit by number + heating_circuit = next( + ( + hc + for hc in heating_circuits + if hc.get("number") == heating_circuit_number + ), + None, + ) + + if heating_circuit is None: + _LOGGER.error( + "Heating circuit with number %s not found for zone %s", + heating_circuit_number, + self.zone_name, + ) + self._attr_current_option = NO_HEATING_CIRCUIT_OPTION + else: + self._attr_current_option = heating_circuit.get( + "driverShortSerialNo" + ) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 5d9c4237be8..ba1c9e95683 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -59,6 +59,14 @@ } } }, + "select": { + "heating_circuit": { + "name": "Heating circuit", + "state": { + "no_heating_circuit": "No circuit" + } + } + }, "switch": { "child_lock": { "name": "Child lock" diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index b2b60db9675..2a85b1f31e1 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -23,8 +23,6 @@ async def async_setup_entry( entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -35,10 +33,3 @@ async def async_unload_entry( ) -> bool: """Unload Tankerkoenig config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def _async_update_listener( - hass: HomeAssistant, entry: TankerkoenigConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index b269eaaaf55..9aeb0a80173 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_API_KEY, @@ -229,7 +229,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" def __init__(self) -> None: diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index a308d55e443..f95fc0dbab7 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -21,7 +21,6 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, - CONF_DEVICE_ID, CONF_NAME, CONF_STATE, CONF_UNIQUE_ID, @@ -31,9 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -42,11 +39,19 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -90,27 +95,28 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Alarm Control Panel" -ALARM_CONTROL_PANEL_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, - vol.Optional( - CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name - ): cv.enum(TemplateCodeFormat), - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +ALARM_CONTROL_PANEL_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( + TemplateCodeFormat + ), + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, + } ) +ALARM_CONTROL_PANEL_YAML_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema +) -LEGACY_ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( +ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA = vol.Schema( { vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, @@ -132,59 +138,29 @@ LEGACY_ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( PLATFORM_SCHEMA = ALARM_CONTROL_PANEL_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ALARM_CONTROL_PANELS): cv.schema_with_slug_keys( - LEGACY_ALARM_CONTROL_PANEL_SCHEMA + ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA ), } ) -ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, - vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( - TemplateCodeFormat - ), - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - } +ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: - """Rewrite option configuration to modern configuration.""" - option_config = {**option_config} - - if CONF_VALUE_TEMPLATE in option_config: - option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) - - return option_config - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - _options = rewrite_options_to_modern_conf(_options) - validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options) - async_add_entities( - [ - StateAlarmControlPanelEntity( - hass, - validated_config, - config_entry.entry_id, - ) - ] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateAlarmControlPanelEntity, + ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA, + True, ) @@ -208,11 +184,28 @@ async def async_setup_platform( ) +@callback +def async_create_preview_alarm_control_panel( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateAlarmControlPanelEntity: + """Create a preview alarm control panel.""" + return async_setup_template_preview( + hass, + name, + config, + StateAlarmControlPanelEntity, + ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA, + True, + ) + + class AbstractTemplateAlarmControlPanel( AbstractTemplateEntity, AlarmControlPanelEntity, RestoreEntity ): """Representation of a templated Alarm Control Panel features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -363,12 +356,8 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP unique_id: str | None, ) -> None: """Initialize the panel.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateAlarmControlPanel.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -379,11 +368,6 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -434,11 +418,6 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 6d41a5804b6..e8b8efbda0a 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -22,7 +22,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_DEVICE_CLASS, - CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME_TEMPLATE, CONF_ICON_TEMPLATE, @@ -38,9 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -51,9 +48,17 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID -from .helpers import async_setup_template_platform -from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity +from .const import CONF_AVAILABILITY_TEMPLATE +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_COMMON_SCHEMA, + TemplateEntity, +) from .trigger_entity import TriggerEntity CONF_DELAY_ON = "delay_on" @@ -66,7 +71,7 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -BINARY_SENSOR_SCHEMA = vol.Schema( +BINARY_SENSOR_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_AUTO_OFF): vol.Any(cv.positive_time_period, cv.template), vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template), @@ -75,15 +80,17 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Required(CONF_STATE): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } -).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) - -BINARY_SENSOR_CONFIG_SCHEMA = BINARY_SENSOR_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } ) -LEGACY_BINARY_SENSOR_SCHEMA = vol.All( +BINARY_SENSOR_YAML_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_SCHEMA.schema +) + +BINARY_SENSOR_CONFIG_ENTRY_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + +BINARY_SENSOR_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -108,7 +115,7 @@ LEGACY_BINARY_SENSOR_SCHEMA = vol.All( PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( - LEGACY_BINARY_SENSOR_SCHEMA + BINARY_SENSOR_LEGACY_YAML_SCHEMA ), } ) @@ -140,11 +147,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = BINARY_SENSOR_CONFIG_SCHEMA(_options) - async_add_entities( - [StateBinarySensorEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateBinarySensorEntity, + BINARY_SENSOR_CONFIG_ENTRY_SCHEMA, ) @@ -153,14 +161,16 @@ def async_create_preview_binary_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateBinarySensorEntity: """Create a preview sensor.""" - validated_config = BINARY_SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return StateBinarySensorEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateBinarySensorEntity, BINARY_SENSOR_CONFIG_ENTRY_SCHEMA + ) class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity): """A virtual binary sensor that triggers from another sensor.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -169,11 +179,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) unique_id: str | None, ) -> None: """Initialize the Template binary sensor.""" - super().__init__(hass, config=config, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + TemplateEntity.__init__(self, hass, config, unique_id) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._template = config[CONF_STATE] @@ -182,10 +188,6 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) self._delay_on_raw = config.get(CONF_DELAY_ON) self._delay_off = None self._delay_off_raw = config.get(CONF_DELAY_OFF) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) async def async_added_to_hass(self) -> None: """Restore state.""" @@ -258,6 +260,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = BINARY_SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index c52e2dae5a0..d84005ccc28 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -3,19 +3,20 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING import voluptuous as vol from homeassistant.components.button import ( DEVICE_CLASSES_SCHEMA, DOMAIN as BUTTON_DOMAIN, + ENTITY_ID_FORMAT, ButtonEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_NAME +from homeassistant.const import CONF_DEVICE_CLASS from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -23,29 +24,31 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import async_setup_template_entry, async_setup_template_platform +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Template Button" DEFAULT_OPTIMISTIC = False -BUTTON_SCHEMA = vol.Schema( +BUTTON_YAML_SCHEMA = vol.Schema( { vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -CONFIG_BUTTON_SCHEMA = vol.Schema( +BUTTON_CONFIG_ENTRY_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME): cv.template, vol.Optional(CONF_PRESS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } -) +).extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema) async def async_setup_platform( @@ -72,11 +75,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = CONFIG_BUTTON_SCHEMA(_options) - async_add_entities( - [StateButtonEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateButtonEntity, + BUTTON_CONFIG_ENTRY_SCHEMA, ) @@ -84,6 +88,7 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -92,17 +97,16 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): unique_id: str | None, ) -> None: """Initialize the button.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None + TemplateEntity.__init__(self, hass, config, unique_id) + + if TYPE_CHECKING: + assert self._attr_name is not None + # Scripts can be an empty list, therefore we need to check for None if (action := config.get(CONF_PRESS)) is not None: self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 1b3e9986d36..a3311c35563 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -102,57 +102,57 @@ CONFIG_SECTION_SCHEMA = vol.All( { vol.Optional(CONF_ACTIONS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( - binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA + binary_sensor_platform.BINARY_SENSOR_LEGACY_YAML_SCHEMA ), vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( - sensor_platform.LEGACY_SENSOR_SCHEMA + sensor_platform.SENSOR_LEGACY_YAML_SCHEMA ), vol.Optional(CONF_TRIGGERS): cv.TRIGGER_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, vol.Optional(DOMAIN_ALARM_CONTROL_PANEL): vol.All( cv.ensure_list, - [alarm_control_panel_platform.ALARM_CONTROL_PANEL_SCHEMA], + [alarm_control_panel_platform.ALARM_CONTROL_PANEL_YAML_SCHEMA], ), vol.Optional(DOMAIN_BINARY_SENSOR): vol.All( - cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] + cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_YAML_SCHEMA] ), vol.Optional(DOMAIN_BUTTON): vol.All( - cv.ensure_list, [button_platform.BUTTON_SCHEMA] + cv.ensure_list, [button_platform.BUTTON_YAML_SCHEMA] ), vol.Optional(DOMAIN_COVER): vol.All( - cv.ensure_list, [cover_platform.COVER_SCHEMA] + cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA] ), vol.Optional(DOMAIN_FAN): vol.All( - cv.ensure_list, [fan_platform.FAN_SCHEMA] + cv.ensure_list, [fan_platform.FAN_YAML_SCHEMA] ), vol.Optional(DOMAIN_IMAGE): vol.All( - cv.ensure_list, [image_platform.IMAGE_SCHEMA] + cv.ensure_list, [image_platform.IMAGE_YAML_SCHEMA] ), vol.Optional(DOMAIN_LIGHT): vol.All( - cv.ensure_list, [light_platform.LIGHT_SCHEMA] + cv.ensure_list, [light_platform.LIGHT_YAML_SCHEMA] ), vol.Optional(DOMAIN_LOCK): vol.All( - cv.ensure_list, [lock_platform.LOCK_SCHEMA] + cv.ensure_list, [lock_platform.LOCK_YAML_SCHEMA] ), vol.Optional(DOMAIN_NUMBER): vol.All( - cv.ensure_list, [number_platform.NUMBER_SCHEMA] + cv.ensure_list, [number_platform.NUMBER_YAML_SCHEMA] ), vol.Optional(DOMAIN_SELECT): vol.All( - cv.ensure_list, [select_platform.SELECT_SCHEMA] + cv.ensure_list, [select_platform.SELECT_YAML_SCHEMA] ), vol.Optional(DOMAIN_SENSOR): vol.All( - cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] + cv.ensure_list, [sensor_platform.SENSOR_YAML_SCHEMA] ), vol.Optional(DOMAIN_SWITCH): vol.All( - cv.ensure_list, [switch_platform.SWITCH_SCHEMA] + cv.ensure_list, [switch_platform.SWITCH_YAML_SCHEMA] ), vol.Optional(DOMAIN_VACUUM): vol.All( - cv.ensure_list, [vacuum_platform.VACUUM_SCHEMA] + cv.ensure_list, [vacuum_platform.VACUUM_YAML_SCHEMA] ), vol.Optional(DOMAIN_WEATHER): vol.All( - cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + cv.ensure_list, [weather_platform.WEATHER_YAML_SCHEMA] ), }, ), diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index e6cc377bc26..d6fc5768f81 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -50,6 +50,7 @@ from .alarm_control_panel import ( CONF_DISARM_ACTION, CONF_TRIGGER_ACTION, TemplateCodeFormat, + async_create_preview_alarm_control_panel, ) from .binary_sensor import async_create_preview_binary_sensor from .const import CONF_PRESS, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN @@ -63,7 +64,7 @@ from .number import ( DEFAULT_STEP, async_create_preview_number, ) -from .select import CONF_OPTIONS, CONF_SELECT_OPTION +from .select import CONF_OPTIONS, CONF_SELECT_OPTION, async_create_preview_select from .sensor import async_create_preview_sensor from .switch import async_create_preview_switch from .template_entity import TemplateEntity @@ -319,6 +320,7 @@ CONFIG_FLOW = { "user": SchemaFlowMenuStep(TEMPLATE_TYPES), Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep( config_schema(Platform.ALARM_CONTROL_PANEL), + preview="template", validate_user_input=validate_user_input(Platform.ALARM_CONTROL_PANEL), ), Platform.BINARY_SENSOR: SchemaFlowFormStep( @@ -332,6 +334,7 @@ CONFIG_FLOW = { ), Platform.IMAGE: SchemaFlowFormStep( config_schema(Platform.IMAGE), + preview="template", validate_user_input=validate_user_input(Platform.IMAGE), ), Platform.NUMBER: SchemaFlowFormStep( @@ -341,6 +344,7 @@ CONFIG_FLOW = { ), Platform.SELECT: SchemaFlowFormStep( config_schema(Platform.SELECT), + preview="template", validate_user_input=validate_user_input(Platform.SELECT), ), Platform.SENSOR: SchemaFlowFormStep( @@ -360,6 +364,7 @@ OPTIONS_FLOW = { "init": SchemaFlowFormStep(next_step=choose_options_step), Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep( options_schema(Platform.ALARM_CONTROL_PANEL), + preview="template", validate_user_input=validate_user_input(Platform.ALARM_CONTROL_PANEL), ), Platform.BINARY_SENSOR: SchemaFlowFormStep( @@ -373,6 +378,7 @@ OPTIONS_FLOW = { ), Platform.IMAGE: SchemaFlowFormStep( options_schema(Platform.IMAGE), + preview="template", validate_user_input=validate_user_input(Platform.IMAGE), ), Platform.NUMBER: SchemaFlowFormStep( @@ -382,6 +388,7 @@ OPTIONS_FLOW = { ), Platform.SELECT: SchemaFlowFormStep( options_schema(Platform.SELECT), + preview="template", validate_user_input=validate_user_input(Platform.SELECT), ), Platform.SENSOR: SchemaFlowFormStep( @@ -400,10 +407,12 @@ CREATE_PREVIEW_ENTITY: dict[ str, Callable[[HomeAssistant, str, dict[str, Any]], TemplateEntity], ] = { - "binary_sensor": async_create_preview_binary_sensor, - "number": async_create_preview_number, - "sensor": async_create_preview_sensor, - "switch": async_create_preview_switch, + Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, + Platform.BINARY_SENSOR: async_create_preview_binary_sensor, + Platform.NUMBER: async_create_preview_number, + Platform.SELECT: async_create_preview_select, + Platform.SENSOR: async_create_preview_sensor, + Platform.SWITCH: async_create_preview_switch, } diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 53c0fa3af13..e3e0e4fe9f5 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -1,6 +1,9 @@ """Constants for the Template Platform Components.""" -from homeassistant.const import Platform +import voluptuous as vol + +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, Platform +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" @@ -16,6 +19,15 @@ CONF_STEP = "step" CONF_TURN_OFF = "turn_off" CONF_TURN_ON = "turn_on" +TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + DOMAIN = "template" PLATFORM_STORAGE_KEY = "template_platforms" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 9d6391d80c9..0bbc6b77f57 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -32,12 +32,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( @@ -92,7 +91,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Cover" -COVER_SCHEMA = vol.All( +COVER_YAML_SCHEMA = vol.All( vol.Schema( { vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, @@ -111,7 +110,7 @@ COVER_SCHEMA = vol.All( cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) -LEGACY_COVER_SCHEMA = vol.All( +COVER_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -135,7 +134,7 @@ LEGACY_COVER_SCHEMA = vol.All( ) PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(LEGACY_COVER_SCHEMA)} + {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_LEGACY_YAML_SCHEMA)} ) @@ -162,6 +161,8 @@ async def async_setup_platform( class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): """Representation of a template cover features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -397,12 +398,8 @@ class StateCoverEntity(TemplateEntity, AbstractTemplateCover): unique_id, ) -> None: """Initialize the Template cover.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateCover.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 3617d9acdee..31c48917a1f 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -1,32 +1,49 @@ """Template entity base class.""" +from abc import abstractmethod from collections.abc import Sequence from typing import Any +from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.script import Script, _VarsType from homeassistant.helpers.template import TemplateStateFromEntityId +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_OBJECT_ID class AbstractTemplateEntity(Entity): """Actions linked to a template entity.""" - def __init__(self, hass: HomeAssistant) -> None: + _entity_id_format: str + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Initialize the entity.""" self.hass = hass self._action_scripts: dict[str, Script] = {} + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + self._entity_id_format, object_id, hass=self.hass + ) + + device_registry = dr.async_get(hass) + if (device_id := config.get(CONF_DEVICE_ID)) is not None: + self.device_entry = device_registry.async_get(device_id) + @property + @abstractmethod def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" - raise NotImplementedError @callback + @abstractmethod def _render_script_variables(self) -> dict: """Render configured variables.""" - raise NotImplementedError def add_script( self, diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 95086375f4b..13d2414aea2 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -34,11 +34,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -82,7 +81,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Fan" -FAN_SCHEMA = vol.All( +FAN_YAML_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_DIRECTION): cv.template, @@ -102,7 +101,7 @@ FAN_SCHEMA = vol.All( ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) -LEGACY_FAN_SCHEMA = vol.All( +FAN_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -127,7 +126,7 @@ LEGACY_FAN_SCHEMA = vol.All( ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_FANS): cv.schema_with_slug_keys(LEGACY_FAN_SCHEMA)} + {vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_LEGACY_YAML_SCHEMA)} ) @@ -154,6 +153,8 @@ async def async_setup_platform( class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): """Representation of a template fan features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -436,12 +437,8 @@ class StateFanEntity(TemplateEntity, AbstractTemplateFan): unique_id, ) -> None: """Initialize the fan.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateFan.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 514255f417a..c0177e9dd5d 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -5,14 +5,19 @@ import itertools import logging from typing import Any +import voluptuous as vol + from homeassistant.components import blueprint +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_STATE, CONF_UNIQUE_ID, + CONF_VALUE_TEMPLATE, SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, callback @@ -20,6 +25,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import template from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, AddEntitiesCallback, async_get_platforms, ) @@ -228,3 +234,41 @@ async def async_setup_template_platform( discovery_info["entities"], discovery_info["unique_id"], ) + + +async def async_setup_template_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + state_entity_cls: type[TemplateEntity], + config_schema: vol.Schema, + replace_value_template: bool = False, +) -> None: + """Setup the Template from a config entry.""" + options = dict(config_entry.options) + options.pop("template_type") + + if replace_value_template and CONF_VALUE_TEMPLATE in options: + options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) + + validated_config = config_schema(options) + + async_add_entities( + [state_entity_cls(hass, validated_config, config_entry.entry_id)] + ) + + +def async_setup_template_preview[T: TemplateEntity]( + hass: HomeAssistant, + name: str, + config: ConfigType, + state_entity_cls: type[T], + schema: vol.Schema, + replace_value_template: bool = False, +) -> T: + """Setup the Template preview.""" + if replace_value_template and CONF_VALUE_TEMPLATE in config: + config[CONF_STATE] = config.pop(CONF_VALUE_TEMPLATE) + + validated_config = schema(config | {CONF_NAME: name}) + return state_entity_cls(hass, validated_config, None) diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 5f7f06faf4f..b4513fc2447 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -7,13 +7,16 @@ from typing import Any import voluptuous as vol -from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity +from homeassistant.components.image import ( + DOMAIN as IMAGE_DOMAIN, + ENTITY_ID_FORMAT, + ImageEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY_SSL +from homeassistant.const import CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -23,8 +26,9 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE -from .helpers import async_setup_template_platform +from .helpers import async_setup_template_entry, async_setup_template_platform from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TemplateEntity, make_template_entity_common_modern_attributes_schema, ) @@ -36,7 +40,7 @@ DEFAULT_NAME = "Template Image" GET_IMAGE_TIMEOUT = 10 -IMAGE_SCHEMA = vol.Schema( +IMAGE_YAML_SCHEMA = vol.Schema( { vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, @@ -44,14 +48,12 @@ IMAGE_SCHEMA = vol.Schema( ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) -IMAGE_CONFIG_SCHEMA = vol.Schema( +IMAGE_CONFIG_ENTRY_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME): cv.template, vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } -) +).extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema) async def async_setup_platform( @@ -78,11 +80,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = IMAGE_CONFIG_SCHEMA(_options) - async_add_entities( - [StateImageEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateImageEntity, + IMAGE_CONFIG_ENTRY_SCHEMA, ) @@ -91,6 +94,7 @@ class StateImageEntity(TemplateEntity, ImageEntity): _attr_should_poll = False _attr_image_url: str | None = None + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -99,13 +103,9 @@ class StateImageEntity(TemplateEntity, ImageEntity): unique_id: str | None, ) -> None: """Initialize the image.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL]) self._url_template = config[CONF_URL] - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @property def entity_picture(self) -> str | None: @@ -135,6 +135,7 @@ class TriggerImageEntity(TriggerEntity, ImageEntity): """Image entity based on trigger data.""" _attr_image_url: str | None = None + _entity_id_format = ENTITY_ID_FORMAT domain = IMAGE_DOMAIN extra_template_keys = (CONF_URL,) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 438c295ecd5..802fc145427 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -43,13 +43,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( @@ -122,7 +121,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Light" -LIGHT_SCHEMA = vol.Schema( +LIGHT_YAML_SCHEMA = vol.Schema( { vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, @@ -148,7 +147,7 @@ LIGHT_SCHEMA = vol.Schema( } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -LEGACY_LIGHT_SCHEMA = vol.All( +LIGHT_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -187,7 +186,7 @@ PLATFORM_SCHEMA = vol.All( cv.removed(CONF_WHITE_VALUE_ACTION), cv.removed(CONF_WHITE_VALUE_TEMPLATE), LIGHT_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LEGACY_LIGHT_SCHEMA)} + {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_LEGACY_YAML_SCHEMA)} ), ) @@ -215,6 +214,8 @@ async def async_setup_platform( class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Representation of a template lights features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__( # pylint: disable=super-init-not-called @@ -893,12 +894,8 @@ class StateLightEntity(TemplateEntity, AbstractTemplateLight): unique_id: str | None, ) -> None: """Initialize the light.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateLight.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 20bc098d130..a2f1f56bea2 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, + ENTITY_ID_FORMAT, PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, LockEntityFeature, @@ -53,7 +54,7 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -LOCK_SCHEMA = vol.All( +LOCK_YAML_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_CODE_FORMAT): cv.template, @@ -67,7 +68,6 @@ LOCK_SCHEMA = vol.All( ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) - PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_CODE_FORMAT_TEMPLATE): cv.template, @@ -104,6 +104,8 @@ async def async_setup_platform( class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Representation of a template lock features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -283,7 +285,7 @@ class StateLockEntity(TemplateEntity, AbstractTemplateLock): unique_id: str | None, ) -> None: """Initialize the lock.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateLock.__init__(self, config) name = self._attr_name if TYPE_CHECKING: diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index fa1e2790a9d..31a6338f594 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -13,19 +13,18 @@ from homeassistant.components.number import ( DEFAULT_MIN_VALUE, DEFAULT_STEP, DOMAIN as NUMBER_DOMAIN, + ENTITY_ID_FORMAT, NumberEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -34,8 +33,16 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -45,30 +52,31 @@ CONF_SET_VALUE = "set_value" DEFAULT_NAME = "Template Number" DEFAULT_OPTIMISTIC = False -NUMBER_SCHEMA = vol.Schema( +NUMBER_COMMON_SCHEMA = vol.Schema( { - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Required(CONF_STEP): cv.template, - vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } -).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -NUMBER_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, + vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, vol.Required(CONF_STATE): cv.template, vol.Required(CONF_STEP): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_MIN): cv.template, - vol.Optional(CONF_MAX): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } ) +NUMBER_YAML_SCHEMA = ( + vol.Schema( + { + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } + ) + .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) + .extend(NUMBER_COMMON_SCHEMA.schema) +) + +NUMBER_CONFIG_ENTRY_SCHEMA = NUMBER_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -94,11 +102,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = NUMBER_CONFIG_SCHEMA(_options) - async_add_entities( - [StateNumberEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateNumberEntity, + NUMBER_CONFIG_ENTRY_SCHEMA, ) @@ -107,14 +116,16 @@ def async_create_preview_number( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateNumberEntity: """Create a preview number.""" - validated_config = NUMBER_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return StateNumberEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateNumberEntity, NUMBER_CONFIG_ENTRY_SCHEMA + ) class StateNumberEntity(TemplateEntity, NumberEntity): """Representation of a template number.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -123,8 +134,10 @@ class StateNumberEntity(TemplateEntity, NumberEntity): unique_id: str | None, ) -> None: """Initialize the number.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None + TemplateEntity.__init__(self, hass, config, unique_id) + if TYPE_CHECKING: + assert self._attr_name is not None + self._value_template = config[CONF_STATE] self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN) @@ -136,10 +149,6 @@ class StateNumberEntity(TemplateEntity, NumberEntity): self._attr_native_step = DEFAULT_STEP self._attr_native_min_value = DEFAULT_MIN_VALUE self._attr_native_max_value = DEFAULT_MAX_VALUE - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @callback def _async_setup_templates(self) -> None: @@ -188,6 +197,7 @@ class StateNumberEntity(TemplateEntity, NumberEntity): class TriggerNumberEntity(TriggerEntity, NumberEntity): """Number entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = NUMBER_DOMAIN extra_template_keys = ( CONF_STATE, diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 55b5c7375f8..0ad99cd6ae8 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -11,13 +11,13 @@ from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, + ENTITY_ID_FORMAT, SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -27,8 +27,16 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -39,26 +47,28 @@ CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" DEFAULT_OPTIMISTIC = False -SELECT_SCHEMA = vol.Schema( +SELECT_COMMON_SCHEMA = vol.Schema( { - vol.Optional(CONF_STATE): cv.template, - vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Required(ATTR_OPTIONS): cv.template, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } -).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) - - -SELECT_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_OPTIONS): cv.template, + vol.Optional(ATTR_OPTIONS): cv.template, vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + vol.Optional(CONF_STATE): cv.template, } ) +SELECT_YAML_SCHEMA = ( + vol.Schema( + { + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } + ) + .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) + .extend(SELECT_COMMON_SCHEMA.schema) +) + +SELECT_CONFIG_ENTRY_SCHEMA = SELECT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -84,15 +94,30 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = SELECT_CONFIG_SCHEMA(_options) - async_add_entities([TemplateSelect(hass, validated_config, config_entry.entry_id)]) + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + TemplateSelect, + SELECT_CONFIG_ENTRY_SCHEMA, + ) + + +@callback +def async_create_preview_select( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> TemplateSelect: + """Create a preview select.""" + return async_setup_template_preview( + hass, name, config, TemplateSelect, SELECT_CONFIG_ENTRY_SCHEMA + ) class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): """Representation of a template select features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -132,7 +157,7 @@ class TemplateSelect(TemplateEntity, AbstractTemplateSelect): unique_id: str | None, ) -> None: """Initialize the select.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateSelect.__init__(self, config) name = self._attr_name @@ -142,11 +167,6 @@ class TemplateSelect(TemplateEntity, AbstractTemplateSelect): if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script(CONF_SELECT_OPTION, select_option, name, DOMAIN) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - @callback def _async_setup_templates(self) -> None: """Set up templates.""" diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 11fe279fdfb..ff956c50c6e 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, + STATE_CLASSES_SCHEMA, RestoreSensor, SensorDeviceClass, SensorEntity, @@ -25,7 +26,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_CLASS, - CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, @@ -43,21 +43,26 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID -from .helpers import async_setup_template_platform -from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity +from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_COMMON_SCHEMA, + TemplateEntity, +) from .trigger_entity import TriggerEntity LEGACY_FIELDS = { @@ -79,29 +84,31 @@ def validate_last_reset(val): return val -SENSOR_SCHEMA = vol.All( +SENSOR_COMMON_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +) + +SENSOR_YAML_SCHEMA = vol.All( vol.Schema( { - vol.Required(CONF_STATE): cv.template, vol.Optional(ATTR_LAST_RESET): cv.template, } ) - .extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) + .extend(SENSOR_COMMON_SCHEMA.schema) .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema), validate_last_reset, ) - -SENSOR_CONFIG_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } - ).extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema), +SENSOR_CONFIG_ENTRY_SCHEMA = SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -LEGACY_SENSOR_SCHEMA = vol.All( +SENSOR_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -143,7 +150,9 @@ PLATFORM_SCHEMA = vol.All( { vol.Optional(CONF_TRIGGER): cv.match_all, # to raise custom warning vol.Optional(CONF_TRIGGERS): cv.match_all, # to raise custom warning - vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(LEGACY_SENSOR_SCHEMA), + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( + SENSOR_LEGACY_YAML_SCHEMA + ), } ), extra_validation_checks, @@ -178,11 +187,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = SENSOR_CONFIG_SCHEMA(_options) - async_add_entities( - [StateSensorEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateSensorEntity, + SENSOR_CONFIG_ENTRY_SCHEMA, ) @@ -191,14 +201,16 @@ def async_create_preview_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateSensorEntity: """Create a preview sensor.""" - validated_config = SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return StateSensorEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateSensorEntity, SENSOR_CONFIG_ENTRY_SCHEMA + ) class StateSensorEntity(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -207,7 +219,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + super().__init__(hass, config, unique_id) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS) @@ -215,14 +227,6 @@ class StateSensorEntity(TemplateEntity, SensorEntity): self._attr_last_reset_template: template.Template | None = config.get( ATTR_LAST_RESET ) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) @callback def _async_setup_templates(self) -> None: @@ -266,6 +270,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): class TriggerSensorEntity(TriggerEntity, RestoreSensor): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index e2ccb5a8a82..b1d72084ae7 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, - CONF_DEVICE_ID, CONF_NAME, CONF_STATE, CONF_SWITCHES, @@ -29,9 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -40,9 +37,14 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN -from .helpers import async_setup_template_platform +from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, @@ -57,16 +59,19 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Switch" - -SWITCH_SCHEMA = vol.Schema( +SWITCH_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_STATE): cv.template, - vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, } -).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +) -LEGACY_SWITCH_SCHEMA = vol.All( +SWITCH_YAML_SCHEMA = SWITCH_COMMON_SCHEMA.extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema +) + +SWITCH_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -81,17 +86,11 @@ LEGACY_SWITCH_SCHEMA = vol.All( ) PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(LEGACY_SWITCH_SCHEMA)} + {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_LEGACY_YAML_SCHEMA)} ) -SWITCH_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } +SWITCH_CONFIG_ENTRY_SCHEMA = SWITCH_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) @@ -131,12 +130,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - _options = rewrite_options_to_modern_conf(_options) - validated_config = SWITCH_CONFIG_SCHEMA(_options) - async_add_entities( - [StateSwitchEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateSwitchEntity, + SWITCH_CONFIG_ENTRY_SCHEMA, + True, ) @@ -145,15 +145,21 @@ def async_create_preview_switch( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateSwitchEntity: """Create a preview switch.""" - updated_config = rewrite_options_to_modern_conf(config) - validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name}) - return StateSwitchEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, + name, + config, + StateSwitchEntity, + SWITCH_CONFIG_ENTRY_SCHEMA, + True, + ) class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): """Representation of a Template switch.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -162,11 +168,8 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__(hass, config=config, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + super().__init__(hass, config, unique_id) + name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -180,10 +183,6 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): self._state: bool | None = False self._attr_assumed_state = self._template is None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @callback def _update_state(self, result): @@ -246,6 +245,7 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): """Switch entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = SWITCH_DOMAIN def __init__( @@ -256,6 +256,7 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) self._template = config.get(CONF_STATE) if on_action := config.get(CONF_TURN_ON): @@ -268,11 +269,6 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): self._to_render_simple.append(CONF_STATE) self._parse_result.add(CONF_STATE) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index e404821e651..ae473854502 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components.blueprint import CONF_USE_BLUEPRINT from homeassistant.const import ( + CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_ICON, CONF_ICON_TEMPLATE, @@ -30,7 +31,7 @@ from homeassistant.core import ( validate_state, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( TrackTemplate, @@ -46,7 +47,6 @@ from homeassistant.helpers.template import ( result_as_boolean, ) from homeassistant.helpers.trigger_template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, make_template_entity_base_schema, ) from homeassistant.helpers.typing import ConfigType @@ -57,6 +57,7 @@ from .const import ( CONF_AVAILABILITY, CONF_AVAILABILITY_TEMPLATE, CONF_PICTURE, + TEMPLATE_ENTITY_BASE_SCHEMA, ) from .entity import AbstractTemplateEntity @@ -91,6 +92,13 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = ( .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) ) +TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.template, + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + } +).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + def make_template_entity_common_modern_schema( default_name: str, @@ -240,17 +248,11 @@ class TemplateEntity(AbstractTemplateEntity): def __init__( self, hass: HomeAssistant, - *, - availability_template: Template | None = None, - icon_template: Template | None = None, - entity_picture_template: Template | None = None, - attribute_templates: dict[str, Template] | None = None, - config: ConfigType | None = None, - fallback_name: str | None = None, - unique_id: str | None = None, + config: ConfigType, + unique_id: str | None, ) -> None: """Template Entity.""" - AbstractTemplateEntity.__init__(self, hass) + AbstractTemplateEntity.__init__(self, hass, config) self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} @@ -269,22 +271,13 @@ class TemplateEntity(AbstractTemplateEntity): | None ) = None self._run_variables: ScriptVariables | dict - if config is None: - self._attribute_templates = attribute_templates - self._availability_template = availability_template - self._icon_template = icon_template - self._entity_picture_template = entity_picture_template - self._friendly_name_template = None - self._run_variables = {} - self._blueprint_inputs = None - else: - self._attribute_templates = config.get(CONF_ATTRIBUTES) - self._availability_template = config.get(CONF_AVAILABILITY) - self._icon_template = config.get(CONF_ICON) - self._entity_picture_template = config.get(CONF_PICTURE) - self._friendly_name_template = config.get(CONF_NAME) - self._run_variables = config.get(CONF_VARIABLES, {}) - self._blueprint_inputs = config.get("raw_blueprint_inputs") + self._attribute_templates = config.get(CONF_ATTRIBUTES) + self._availability_template = config.get(CONF_AVAILABILITY) + self._icon_template = config.get(CONF_ICON) + self._entity_picture_template = config.get(CONF_PICTURE) + self._friendly_name_template = config.get(CONF_NAME) + self._run_variables = config.get(CONF_VARIABLES, {}) + self._blueprint_inputs = config.get("raw_blueprint_inputs") class DummyState(State): """None-state for template entities not yet added to the state machine.""" @@ -302,7 +295,7 @@ class TemplateEntity(AbstractTemplateEntity): variables = {"this": DummyState()} # Try to render the name as it can influence the entity ID - self._attr_name = fallback_name + self._attr_name = None if self._friendly_name_template: with contextlib.suppress(TemplateError): self._attr_name = self._friendly_name_template.async_render( diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 4565e86843a..66c57eb2aab 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -30,7 +30,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Initialize the entity.""" CoordinatorEntity.__init__(self, coordinator) TriggerBaseEntity.__init__(self, hass, config) - AbstractTemplateEntity.__init__(self, hass) + AbstractTemplateEntity.__init__(self, hass, config) self._state_render_error = False diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index d9c416f4863..0056eca9b99 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -34,11 +34,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -77,7 +76,7 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -VACUUM_SCHEMA = vol.All( +VACUUM_YAML_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_BATTERY_LEVEL): cv.template, @@ -95,7 +94,7 @@ VACUUM_SCHEMA = vol.All( ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) ) -LEGACY_VACUUM_SCHEMA = vol.All( +VACUUM_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -120,7 +119,7 @@ LEGACY_VACUUM_SCHEMA = vol.All( ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(LEGACY_VACUUM_SCHEMA)} + {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(VACUUM_LEGACY_YAML_SCHEMA)} ) @@ -147,6 +146,8 @@ async def async_setup_platform( class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -302,12 +303,8 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): unique_id, ) -> None: """Initialize the vacuum.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateVacuum.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 66ead388d5d..7f79adc2201 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -31,11 +31,15 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + CONF_NAME, + CONF_TEMPERATURE_UNIT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -86,6 +90,7 @@ CONF_PRESSURE_TEMPLATE = "pressure_template" CONF_WIND_SPEED_TEMPLATE = "wind_speed_template" CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" CONF_OZONE_TEMPLATE = "ozone_template" +CONF_UV_INDEX_TEMPLATE = "uv_index_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" CONF_FORECAST_DAILY_TEMPLATE = "forecast_daily_template" CONF_FORECAST_HOURLY_TEMPLATE = "forecast_hourly_template" @@ -101,7 +106,7 @@ CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" DEFAULT_NAME = "Template Weather" -WEATHER_SCHEMA = vol.Schema( +WEATHER_YAML_SCHEMA = vol.Schema( { vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, @@ -118,6 +123,7 @@ WEATHER_SCHEMA = vol.Schema( vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_UV_INDEX_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, @@ -127,7 +133,32 @@ WEATHER_SCHEMA = vol.Schema( } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) +PLATFORM_SCHEMA = vol.Schema( + { + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), + } +).extend(WEATHER_PLATFORM_SCHEMA.schema) async def async_setup_platform( @@ -153,6 +184,7 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -161,9 +193,8 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): unique_id: str | None, ) -> None: """Initialize the Template weather.""" - super().__init__(hass, config=config, unique_id=unique_id) + super().__init__(hass, config, unique_id) - name = self._attr_name self._condition_template = config[CONF_CONDITION_TEMPLATE] self._temperature_template = config[CONF_TEMPERATURE_TEMPLATE] self._humidity_template = config[CONF_HUMIDITY_TEMPLATE] @@ -172,6 +203,7 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): self._wind_speed_template = config.get(CONF_WIND_SPEED_TEMPLATE) self._wind_bearing_template = config.get(CONF_WIND_BEARING_TEMPLATE) self._ozone_template = config.get(CONF_OZONE_TEMPLATE) + self._uv_index_template = config.get(CONF_UV_INDEX_TEMPLATE) self._visibility_template = config.get(CONF_VISIBILITY_TEMPLATE) self._forecast_daily_template = config.get(CONF_FORECAST_DAILY_TEMPLATE) self._forecast_hourly_template = config.get(CONF_FORECAST_HOURLY_TEMPLATE) @@ -191,8 +223,6 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT) self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT) - self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass) - self._condition = None self._temperature = None self._humidity = None @@ -201,6 +231,7 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): self._wind_speed = None self._wind_bearing = None self._ozone = None + self._uv_index = None self._visibility = None self._wind_gust_speed = None self._cloud_coverage = None @@ -248,6 +279,11 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): """Return the ozone level.""" return self._ozone + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return self._uv_index + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -342,6 +378,11 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): "_ozone", self._ozone_template, ) + if self._uv_index_template: + self.add_template_attribute( + "_uv_index", + self._uv_index_template, + ) if self._visibility_template: self.add_template_attribute( "_visibility", @@ -453,6 +494,7 @@ class WeatherExtraStoredData(ExtraStoredData): last_ozone: float | None last_pressure: float | None last_temperature: float | None + last_uv_index: float | None last_visibility: float | None last_wind_bearing: float | str | None last_wind_gust_speed: float | None @@ -474,6 +516,7 @@ class WeatherExtraStoredData(ExtraStoredData): last_ozone=restored["last_ozone"], last_pressure=restored["last_pressure"], last_temperature=restored["last_temperature"], + last_uv_index=restored["last_uv_index"], last_visibility=restored["last_visibility"], last_wind_bearing=restored["last_wind_bearing"], last_wind_gust_speed=restored["last_wind_gust_speed"], @@ -486,6 +529,7 @@ class WeatherExtraStoredData(ExtraStoredData): class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = WEATHER_DOMAIN extra_template_keys = ( CONF_CONDITION_TEMPLATE, @@ -501,6 +545,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): ) -> None: """Initialize.""" super().__init__(hass, coordinator, config) + self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT) self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT) self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT) @@ -524,6 +569,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): CONF_FORECAST_TWICE_DAILY_TEMPLATE, CONF_OZONE_TEMPLATE, CONF_PRESSURE_TEMPLATE, + CONF_UV_INDEX_TEMPLATE, CONF_VISIBILITY_TEMPLATE, CONF_WIND_BEARING_TEMPLATE, CONF_WIND_GUST_SPEED_TEMPLATE, @@ -554,6 +600,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): self._rendered[CONF_OZONE_TEMPLATE] = weather_data.last_ozone self._rendered[CONF_PRESSURE_TEMPLATE] = weather_data.last_pressure self._rendered[CONF_TEMPERATURE_TEMPLATE] = weather_data.last_temperature + self._rendered[CONF_UV_INDEX_TEMPLATE] = weather_data.last_uv_index self._rendered[CONF_VISIBILITY_TEMPLATE] = weather_data.last_visibility self._rendered[CONF_WIND_BEARING_TEMPLATE] = weather_data.last_wind_bearing self._rendered[CONF_WIND_GUST_SPEED_TEMPLATE] = ( @@ -601,6 +648,13 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): self._rendered.get(CONF_OZONE_TEMPLATE), ) + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_UV_INDEX_TEMPLATE) + ) + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -674,6 +728,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): last_ozone=self._rendered.get(CONF_OZONE_TEMPLATE), last_pressure=self._rendered.get(CONF_PRESSURE_TEMPLATE), last_temperature=self._rendered.get(CONF_TEMPERATURE_TEMPLATE), + last_uv_index=self._rendered.get(CONF_UV_INDEX_TEMPLATE), last_visibility=self._rendered.get(CONF_VISIBILITY_TEMPLATE), last_wind_bearing=self._rendered.get(CONF_WIND_BEARING_TEMPLATE), last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE), diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 3ffc6c43efb..688a254a731 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -133,7 +133,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) firmware = vehicle_metadata[vin].get("firmware", "Unknown") stream_vehicle = stream.get_vehicle(vin) - poll = product["command_signing"] == "off" + poll = vehicle_metadata[vin].get("polling", False) vehicles.append( TeslemetryVehicleData( diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 6905cefdc30..5db73c7aa06 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -542,7 +542,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index cf1d6157ec1..12772b894b6 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry -from .entity import TeslemetryVehiclePollingEntity +from .entity import TeslemetryVehicleStreamEntity from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryVehicleData @@ -74,7 +74,7 @@ async def async_setup_entry( ) -class TeslemetryButtonEntity(TeslemetryVehiclePollingEntity, ButtonEntity): +class TeslemetryButtonEntity(TeslemetryVehicleStreamEntity, ButtonEntity): """Base class for Teslemetry buttons.""" api: Vehicle diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 1bc52b23026..000e1b136c8 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -67,7 +67,7 @@ async def async_setup_entry( TeslemetryVehiclePollingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) @@ -77,7 +77,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index f6ff71ab0cc..5c86d6e19fe 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -45,7 +45,7 @@ async def async_setup_entry( chain( ( TeslemetryVehiclePollingWindowEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ), @@ -53,7 +53,7 @@ async def async_setup_entry( TeslemetryVehiclePollingChargePortEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingChargePortEntity( vehicle, entry.runtime_data.scopes ) @@ -63,7 +63,7 @@ async def async_setup_entry( TeslemetryVehiclePollingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -73,7 +73,7 @@ async def async_setup_entry( TeslemetryVehiclePollingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -82,7 +82,8 @@ async def async_setup_entry( ( TeslemetrySunroofEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles - if vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") + if vehicle.poll + and vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") ), ) ) diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index eb2c220ebbd..0e1b3edf69a 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -89,7 +89,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in DESCRIPTIONS: - if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware: + if vehicle.poll or vehicle.firmware < description.streaming_firmware: if description.polling_prefix: entities.append( TeslemetryVehiclePollingDeviceTrackerEntity( diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index fda52357f5c..7e98d6338ba 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -42,7 +42,7 @@ async def async_setup_entry( TeslemetryVehiclePollingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) @@ -52,7 +52,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index bf1fffed583..9ffc02e4307 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -53,7 +53,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingMediaEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2025.2.6" + if vehicle.poll or vehicle.firmware < "2025.2.6" else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index bb9f5b588a0..bccefcaf6cb 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -145,7 +145,7 @@ async def async_setup_entry( description, entry.runtime_data.scopes, ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingNumberEntity( vehicle, description, diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index c24c47feb2e..fec54b75880 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -180,7 +180,7 @@ async def async_setup_entry( TeslemetryVehiclePollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) - if vehicle.api.pre2021 + if vehicle.poll or vehicle.firmware < "2024.26" or description.streaming_listener is None else TeslemetryStreamingSelectEntity( diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index b50c9b4d0ce..1ffe073cc5c 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -1565,7 +1565,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): @@ -1575,7 +1575,7 @@ async def async_setup_entry( for time_description in VEHICLE_TIME_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and vehicle.firmware >= time_description.streaming_firmware ): entities.append( diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index f607429be46..aae973cf315 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -147,8 +147,7 @@ async def async_setup_entry( TeslemetryVehiclePollingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) - if vehicle.api.pre2021 - or vehicle.firmware < description.streaming_firmware + if vehicle.poll or vehicle.firmware < description.streaming_firmware else TeslemetryStreamingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 144a97039fc..7e0b727ba79 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -39,7 +39,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingUpdateEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 19f88817e71..7cdb0c02f5b 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -42,7 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> b ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -53,11 +52,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: TVTrainConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool: """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", entry.version) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index fb39e14815e..2328a7126fd 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant, callback @@ -329,7 +329,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): ) -class TVTrainOptionsFlowHandler(OptionsFlow): +class TVTrainOptionsFlowHandler(OptionsFlowWithReload): """Handle Trafikverket Train options.""" async def async_step_init( diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index afe2660e711..458f719e5f2 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -60,12 +60,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): @property def limit(self) -> int: """Return limit.""" - return self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT) + return self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT) # type: ignore[no-any-return] @property def order(self) -> str: """Return order.""" - return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) + return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) # type: ignore[no-any-return] async def _async_update_data(self) -> SessionStats: """Update transmission data.""" diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 734f6ba7f7a..d8907b0db9d 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from tuya_sharing import CustomerDevice, Manager @@ -250,6 +250,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): ) # Determine fan modes + self._fan_mode_dp_code: str | None = None if enum_type := self.find_dpcode( (DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), dptype=DPType.ENUM, @@ -257,6 +258,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): ): self._attr_supported_features |= ClimateEntityFeature.FAN_MODE self._attr_fan_modes = enum_type.range + self._fan_mode_dp_code = enum_type.dpcode # Determine swing modes if self.find_dpcode( @@ -304,7 +306,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - self._send_command([{"code": DPCode.FAN_SPEED_ENUM, "value": fan_mode}]) + if TYPE_CHECKING: + # We can rely on supported_features from __init__ + assert self._fan_mode_dp_code is not None + + self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}]) def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" @@ -460,7 +466,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): @property def fan_mode(self) -> str | None: """Return fan mode.""" - return self.device.status.get(DPCode.FAN_SPEED_ENUM) + return ( + self.device.status.get(self._fan_mode_dp_code) + if self._fan_mode_dp_code + else None + ) @property def swing_mode(self) -> str: diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 61da1239554..87f80755e8b 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -98,6 +98,7 @@ class DPCode(StrEnum): AIR_QUALITY = "air_quality" AIR_QUALITY_INDEX = "air_quality_index" + ALARM_DELAY_TIME = "alarm_delay_time" ALARM_SWITCH = "alarm_switch" # Alarm switch ALARM_TIME = "alarm_time" # Alarm time ALARM_VOLUME = "alarm_volume" # Alarm volume @@ -176,6 +177,7 @@ class DPCode(StrEnum): DECIBEL_SWITCH = "decibel_switch" DEHUMIDITY_SET_ENUM = "dehumidify_set_enum" DEHUMIDITY_SET_VALUE = "dehumidify_set_value" + DELAY_SET = "delay_set" DISINFECTION = "disinfection" DO_NOT_DISTURB = "do_not_disturb" DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor @@ -352,6 +354,7 @@ class DPCode(StrEnum): TEMP_BOILING_C = "temp_boiling_c" TEMP_BOILING_F = "temp_boiling_f" TEMP_CONTROLLER = "temp_controller" + TEMP_CORRECTION = "temp_correction" TEMP_CURRENT = "temp_current" # Current temperature in °C TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F TEMP_CURRENT_EXTERNAL = ( @@ -406,6 +409,7 @@ class DPCode(StrEnum): WIRELESS_ELECTRICITY = "wireless_electricity" WORK_MODE = "work_mode" # Working mode WORK_POWER = "work_power" + WORK_STATE_E = "work_state_e" @dataclass diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index f96ea2c0a65..90f4132cef0 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -26,11 +26,23 @@ from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData TUYA_SUPPORT_TYPE = { - "cs", # Dehumidifier - "fs", # Fan - "fsd", # Fan with Light - "fskg", # Fan wall switch - "kj", # Air Purifier + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs", + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs", + # Ceiling Fan Light + # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + "fsd", + # Fan wall switch + "fskg", + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj", + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks", } diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 3f8fc7d0fb9..b6d0332e03a 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -242,6 +242,15 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + translation_key="backlight", + entity_category=EntityCategory.CONFIG, + ), + ), # Unknown light product # Found as VECINO RGBW as provided by diagnostics # Not documented diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index cb248d42739..415299307e3 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -5,6 +5,7 @@ from __future__ import annotations from tuya_sharing import CustomerDevice, Manager from homeassistant.components.number import ( + DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -15,7 +16,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import ( + DEVICE_CLASS_UNITS, + DOMAIN, + LOGGER, + TUYA_DISCOVERY_NEW, + DPCode, + DPType, +) from .entity import TuyaEntity from .models import IntegerTypeData @@ -162,6 +170,30 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk + "mal": ( + NumberEntityDescription( + key=DPCode.DELAY_SET, + # This setting is called "Arm Delay" in the official Tuya app + translation_key="arm_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ALARM_DELAY_TIME, + translation_key="alarm_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ALARM_TIME, + # This setting is called "Siren Duration" in the official Tuya app + translation_key="siren_duration", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), # Sous Vide Cooker # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux "mzj": ( @@ -287,6 +319,15 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + NumberEntityDescription( + key=DPCode.TEMP_CORRECTION, + translation_key="temp_correction", + entity_category=EntityCategory.CONFIG, + ), + ), # Vibration Sensor # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno "zd": ( @@ -364,6 +405,8 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self._attr_native_max_value = self._number.max_scaled self._attr_native_min_value = self._number.min_scaled self._attr_native_step = self._number.step_scaled + if description.native_unit_of_measurement is None: + self._attr_native_unit_of_measurement = int_type.unit # Logic to ensure the set device class and API received Unit Of Measurement # match Home Assistants requirements. @@ -371,6 +414,9 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self.device_class is not None and not self.device_class.startswith(DOMAIN) and description.native_unit_of_measurement is None + # we do not need to check mappings if the API UOM is allowed + and self.native_unit_of_measurement + not in NUMBER_DEVICE_CLASS_UNITS[self.device_class] ): # We cannot have a device class, if the UOM isn't set or the # device class cannot be found in the validation mapping. @@ -378,6 +424,12 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self.native_unit_of_measurement is None or self.device_class not in DEVICE_CLASS_UNITS ): + LOGGER.debug( + "Device class %s ignored for incompatible unit %s in number entity %s", + self.device_class, + self.native_unit_of_measurement, + self.unique_id, + ) self._attr_device_class = None return diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 4ad4355f876..22229b3f6bf 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -55,6 +55,15 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + SelectEntityDescription( + key=DPCode.WORK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="odor_elimination_mode", + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 9caf642d403..6e8da29ef53 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -8,6 +8,7 @@ from tuya_sharing import CustomerDevice, Manager from tuya_sharing.device import DeviceStatusRange from homeassistant.components.sensor import ( + DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -32,6 +33,7 @@ from . import TuyaConfigEntry from .const import ( DEVICE_CLASS_UNITS, DOMAIN, + LOGGER, TUYA_DISCOVERY_NEW, DPCode, DPType, @@ -218,6 +220,15 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + TuyaSensorEntityDescription( + key=DPCode.WORK_STATE_E, + translation_key="odor_elimination_status", + ), + *BATTERY_SENSORS, + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( @@ -1066,6 +1077,9 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": (*BATTERY_SENSORS,), # Two-way temperature and humidity switch # "MOES Temperature and Humidity Smart Switch Module MS-103" # Documentation not found @@ -1438,6 +1452,9 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self.device_class is not None and not self.device_class.startswith(DOMAIN) and description.native_unit_of_measurement is None + # we do not need to check mappings if the API UOM is allowed + and self.native_unit_of_measurement + not in SENSOR_DEVICE_CLASS_UNITS[self.device_class] ): # We cannot have a device class, if the UOM isn't set or the # device class cannot be found in the validation mapping. @@ -1445,6 +1462,12 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self.native_unit_of_measurement is None or self.device_class not in DEVICE_CLASS_UNITS ): + LOGGER.debug( + "Device class %s ignored for incompatible unit %s in sensor entity %s", + self.device_class, + self.native_unit_of_measurement, + self.unique_id, + ) self._attr_device_class = None return diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index a5302b2e88b..799d57547b2 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -219,6 +219,18 @@ }, "down_delay": { "name": "Down delay" + }, + "temp_correction": { + "name": "Temperature correction" + }, + "arm_delay": { + "name": "Arm delay" + }, + "alarm_delay": { + "name": "Alarm delay" + }, + "siren_duration": { + "name": "Siren duration" } }, "select": { @@ -485,6 +497,13 @@ "level_9": "Level 9", "level_10": "High" } + }, + "odor_elimination_mode": { + "name": "Odor elimination mode", + "state": { + "smart": "Smart", + "interim": "Interim" + } } }, "sensor": { @@ -697,6 +716,15 @@ }, "water_time": { "name": "Water usage duration" + }, + "odor_elimination_status": { + "name": "Status", + "state": { + "work": "Working", + "standby": "[%key:common::state::standby%]", + "charging": "[%key:common::state::charging%]", + "charge_done": "Charge done" + } } }, "switch": { diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index f455424c2c1..67f3ba9cb81 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -85,6 +85,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( @@ -431,6 +439,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="ionizer", + ), + ), # Alarm Host # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk "mal": ( @@ -529,6 +545,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), + # AC charging + # Not documented + "qccdz": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), # Unknown product with switch capabilities # Fond in some diffusers, plugs and PIR flood lights # Not documented diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 2d75010b4e5..440250d45a3 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -114,7 +114,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) entry.runtime_data = data_service - entry.async_on_unload(entry.add_update_listener(_async_options_updated)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop) ) @@ -139,11 +138,6 @@ async def _async_setup_entry( hass.http.register_view(VideoEventProxyView(hass)) -async def _async_options_updated(hass: HomeAssistant, entry: UFPConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: """Unload UniFi Protect config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 9f7f4bccd7f..c83b3f11010 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -223,7 +223,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -372,7 +372,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index baecc7f8323..1c03febe74b 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -93,12 +93,12 @@ class ProtectData: @property def disable_stream(self) -> bool: """Check if RTSP is disabled.""" - return self._entry.options.get(CONF_DISABLE_RTSP, False) + return self._entry.options.get(CONF_DISABLE_RTSP, False) # type: ignore[no-any-return] @property def max_events(self) -> int: """Max number of events to load at once.""" - return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) + return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) # type: ignore[no-any-return] @callback def async_subscribe_adopt( diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 8243a55d779..e5b017e0ab6 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.14.2", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.16.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 62ee4ede7d9..825c5774c1d 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py index 9866f08bef3..da71084d1bc 100644 --- a/homeassistant/components/uptime_kuma/config_flow.py +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -15,6 +16,7 @@ from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( TextSelector, @@ -38,6 +40,30 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Optional(CONF_API_KEY, default=""): str, } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_API_KEY, default=""): str}) + + +async def validate_connection( + hass: HomeAssistant, + url: URL | str, + verify_ssl: bool, + api_key: str, +) -> dict[str, str]: + """Validate Uptime Kuma connectivity.""" + errors: dict[str, str] = {} + session = async_get_clientsession(hass, verify_ssl) + uptime_kuma = UptimeKuma(session, url, api_key) + + try: + await uptime_kuma.metrics() + except UptimeKumaAuthenticationException: + errors["base"] = "invalid_auth" + except UptimeKumaException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): @@ -52,19 +78,14 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): url = URL(user_input[CONF_URL]) self._async_abort_entries_match({CONF_URL: url.human_repr()}) - session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) - uptime_kuma = UptimeKuma(session, url, user_input[CONF_API_KEY]) - - try: - await uptime_kuma.metrics() - except UptimeKumaAuthenticationException: - errors["base"] = "invalid_auth" - except UptimeKumaException: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not ( + errors := await validate_connection( + self.hass, + url, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): return self.async_create_entry( title=url.host or "", data={**user_input, CONF_URL: url.human_repr()}, @@ -77,3 +98,73 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + entry = self._get_reauth_entry() + + if user_input is not None: + if not ( + errors := await validate_connection( + self.hass, + entry.data[CONF_URL], + entry.data[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): + return self.async_update_reload_and_abort( + entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + if user_input is not None: + url = URL(user_input[CONF_URL]) + self._async_abort_entries_match({CONF_URL: url.human_repr()}) + + if not ( + errors := await validate_connection( + self.hass, + url, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): + return self.async_update_reload_and_abort( + entry, + data_updates={**user_input, CONF_URL: url.human_repr()}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input or entry.data, + ), + errors=errors, + ) diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index 788d37cfb84..297bd83e7c8 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -16,7 +16,7 @@ from pythonkuma import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -59,7 +59,7 @@ class UptimeKumaDataUpdateCoordinator( try: metrics = await self.api.metrics() except UptimeKumaAuthenticationException as e: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="auth_failed_exception", ) from e diff --git a/homeassistant/components/uptime_kuma/diagnostics.py b/homeassistant/components/uptime_kuma/diagnostics.py new file mode 100644 index 00000000000..48e23adc40d --- /dev/null +++ b/homeassistant/components/uptime_kuma/diagnostics.py @@ -0,0 +1,23 @@ +"""Diagnostics platform for Uptime Kuma.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import UptimeKumaConfigEntry + +TO_REDACT = {"monitor_url", "monitor_hostname"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: UptimeKumaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return async_redact_data( + {k: asdict(v) for k, v in entry.runtime_data.data.items()}, TO_REDACT + ) diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index 6f20d4ae20f..42fac89a976 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pythonkuma"], "quality_scale": "bronze", - "requirements": ["pythonkuma==0.3.0"] + "requirements": ["pythonkuma==0.3.1"] } diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index 145cbf58448..876318c8917 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -38,12 +38,12 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: is not locally discoverable @@ -66,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: has no repairs diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index 8cd361cccea..87dcf6e8cf7 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -13,6 +13,29 @@ "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to an Uptime Kuma instance using a self-signed certificate or via IP address", "api_key": "Enter an API key. To create a new API key navigate to **Settings → API Keys** and select **Add API Key**" } + }, + "reauth_confirm": { + "title": "Re-authenticate with Uptime Kuma: {name}", + "description": "The API key for **{name}** is invalid. To re-authenticate with Uptime Kuma provide a new API key below", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } + }, + "reconfigure": { + "title": "Update configuration for Uptime Kuma", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "[%key:component::uptime_kuma::config::step::user::data_description::url%]", + "verify_ssl": "[%key:component::uptime_kuma::config::step::user::data_description::verify_ssl%]", + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -21,7 +44,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index b8f0b702ebe..aedc174cb6d 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -143,7 +143,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription) ) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True @@ -161,11 +160,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def map_vera_device( vera_device: veraApi.VeraDevice, remap: list[int] ) -> Platform | None: diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index f2b182cc270..f02549e7857 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE from homeassistant.core import callback @@ -73,7 +73,7 @@ def options_data(user_input: dict[str, str]) -> dict[str, list[int]]: ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 17b0fe6e501..0433199b54e 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -25,8 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -39,9 +37,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> await coordinator.api.logout() return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: VodafoneConfigEntry) -> None: - """Update when config_entry options update.""" - if entry.options: - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index c330a93a1a8..13e30d38926 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -12,7 +12,11 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -180,7 +184,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): ) -class VodafoneStationOptionsFlowHandler(OptionsFlow): +class VodafoneStationOptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow.""" async def async_step_init( diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index 9821b5435d9..7b1243ed905 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -4,18 +4,16 @@ from __future__ import annotations from aiowaqi import WAQIClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import WAQIDataUpdateCoordinator +from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool: """Set up World Air Quality Index (WAQI) from a config entry.""" client = WAQIClient(session=async_get_clientsession(hass)) @@ -23,16 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: waqi_coordinator = WAQIDataUpdateCoordinator(hass, entry, client) await waqi_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_coordinator + entry.runtime_data = waqi_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index 51ba801c92e..8ed2dcd8425 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -66,24 +66,22 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(user_input[CONF_API_KEY]) - try: - await waqi_client.get_by_ip() - except WAQIAuthenticationError: - errors["base"] = "invalid_auth" - except WAQIConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - self.data = user_input - if user_input[CONF_METHOD] == CONF_MAP: - return await self.async_step_map() - return await self.async_step_station_number() + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(user_input[CONF_API_KEY]) + try: + await client.get_by_ip() + except WAQIAuthenticationError: + errors["base"] = "invalid_auth" + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.data = user_input + if user_input[CONF_METHOD] == CONF_MAP: + return await self.async_step_map() + return await self.async_step_station_number() return self.async_show_form( step_id="user", @@ -107,22 +105,20 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Add measuring station via map.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(self.data[CONF_API_KEY]) - try: - measuring_station = await waqi_client.get_by_coordinates( - user_input[CONF_LOCATION][CONF_LATITUDE], - user_input[CONF_LOCATION][CONF_LONGITUDE], - ) - except WAQIConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return await self._async_create_entry(measuring_station) + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(self.data[CONF_API_KEY]) + try: + measuring_station = await client.get_by_coordinates( + user_input[CONF_LOCATION][CONF_LATITUDE], + user_input[CONF_LOCATION][CONF_LONGITUDE], + ) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self._async_create_entry(measuring_station) return self.async_show_form( step_id=CONF_MAP, data_schema=self.add_suggested_values_to_schema( @@ -149,21 +145,19 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Add measuring station via station number.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(self.data[CONF_API_KEY]) - station_number = user_input[CONF_STATION_NUMBER] - measuring_station, errors = await get_by_station_number( - waqi_client, abs(station_number) + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(self.data[CONF_API_KEY]) + station_number = user_input[CONF_STATION_NUMBER] + measuring_station, errors = await get_by_station_number( + client, abs(station_number) + ) + if not measuring_station: + measuring_station, _ = await get_by_station_number( + client, + abs(station_number) - station_number - station_number, ) - if not measuring_station: - measuring_station, _ = await get_by_station_number( - waqi_client, - abs(station_number) - station_number - station_number, - ) - if measuring_station: - return await self._async_create_entry(measuring_station) + if measuring_station: + return await self._async_create_entry(measuring_station) return self.async_show_form( step_id=CONF_STATION_NUMBER, data_schema=vol.Schema( diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py index 86f553a86cd..f40df4a1b89 100644 --- a/homeassistant/components/waqi/coordinator.py +++ b/homeassistant/components/waqi/coordinator.py @@ -12,14 +12,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER +type WAQIConfigEntry = ConfigEntry[WAQIDataUpdateCoordinator] + class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): """The WAQI Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: WAQIConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, client: WAQIClient + self, hass: HomeAssistant, config_entry: WAQIConfigEntry, client: WAQIClient ) -> None: """Initialize the WAQI data coordinator.""" super().__init__( diff --git a/homeassistant/components/waqi/icons.json b/homeassistant/components/waqi/icons.json new file mode 100644 index 00000000000..545e49fd54e --- /dev/null +++ b/homeassistant/components/waqi/icons.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "carbon_monoxide": { + "default": "mdi:molecule-co" + }, + "nitrogen_dioxide": { + "default": "mdi:molecule" + }, + "ozone": { + "default": "mdi:molecule" + }, + "sulphur_dioxide": { + "default": "mdi:molecule" + }, + "pm10": { + "default": "mdi:molecule" + }, + "pm25": { + "default": "mdi:molecule" + }, + "neph": { + "default": "mdi:eye" + }, + "dominant_pollutant": { + "default": "mdi:molecule", + "state": { + "co": "mdi:molecule-co", + "neph": "mdi:eye", + "no2": "mdi:molecule", + "o3": "mdi:molecule", + "so2": "mdi:molecule", + "pm10": "mdi:molecule", + "pm25": "mdi:molecule" + } + } + } + } +} diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 59daf60392e..c887d893c08 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -import logging from aiowaqi import WAQIAirQuality from aiowaqi.models import Pollutant @@ -15,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -24,18 +22,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import WAQIDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) - -ATTR_DOMINENTPOL = "dominentpol" -ATTR_HUMIDITY = "humidity" -ATTR_NITROGEN_DIOXIDE = "nitrogen_dioxide" -ATTR_OZONE = "ozone" -ATTR_PM10 = "pm_10" -ATTR_PM2_5 = "pm_2_5" -ATTR_PRESSURE = "pressure" -ATTR_SULFUR_DIOXIDE = "sulfur_dioxide" +from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -139,11 +126,11 @@ SENSORS: list[WAQISensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WAQIConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WAQI sensor.""" - coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( WaqiSensor(coordinator, sensor) for sensor in SENSORS diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index c1a1c698f92..fb729707154 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -75,8 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b ) ) - entry.async_on_unload(entry.add_update_listener(async_update_options)) - async def async_on_stop(_event: Event) -> None: """Unregister callbacks and disconnect.""" client.clear_state_update_callbacks() @@ -88,11 +86,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b return True -async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 2af38cb3d17..44711c2b456 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -9,7 +9,11 @@ from urllib.parse import urlparse from aiowebostv import WebOsClient, WebOsTvPairError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -60,7 +64,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -197,7 +201,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" def __init__(self, config_entry: WebOsTvConfigEntry) -> None: diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index 6cf216011f2..b6811190a27 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -29,8 +29,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up wiffi from a config entry, config_entry contains data from config entry database.""" - if not entry.update_listeners: - entry.add_update_listener(async_update_options) # create api object api = WiffiIntegrationApi(hass) @@ -53,11 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" api: WiffiIntegrationApi = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wiffi/config_flow.py b/homeassistant/components/wiffi/config_flow.py index 308923597cd..c40bd5519e0 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PORT, CONF_TIMEOUT from homeassistant.core import callback @@ -76,7 +76,7 @@ class WiffiFlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Wiffi server setup option flow.""" async def async_step_init( diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index b4834347694..c3917507fb9 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -48,9 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> bool # Set up all platforms for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Reload entry when its updated. - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True @@ -65,8 +62,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> boo coordinator.unsub() return unload_ok - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 2e0b7b1c793..e80760508a0 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback @@ -120,7 +120,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): return await wled.update() -class WLEDOptionsFlowHandler(OptionsFlow): +class WLEDOptionsFlowHandler(OptionsFlowWithReload): """Handle WLED options.""" async def async_step_init( diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 60a0489ec5c..0df4224a4ca 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -94,16 +94,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_options[CONF_LANGUAGE] = default_language hass.config_entries.async_update_entry(entry, options=new_options) - entry.async_on_unload(entry.add_update_listener(async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener for options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Workday config entry.""" diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 7a8a8181a9f..1d91e1d5ae3 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import callback @@ -311,7 +311,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) -class WorkdayOptionsFlowHandler(OptionsFlow): +class WorkdayOptionsFlowHandler(OptionsFlowWithReload): """Handle Workday options.""" async def async_step_init( diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 32c6a11f25c..23a27adeb69 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -100,7 +100,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Close the WS66i connection to the amplifier.""" ws66i.close() - entry.async_on_unload(entry.add_update_listener(_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) ) @@ -119,8 +118,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index 120b7738d2e..e70dbd4e8d7 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant, callback @@ -142,7 +142,7 @@ def _key_for_source( ) -class Ws66iOptionsFlowHandler(OptionsFlow): +class Ws66iOptionsFlowHandler(OptionsFlowWithReload): """Handle a WS66i options flow.""" async def async_step_init( diff --git a/homeassistant/components/xiaomi_ble/coordinator.py b/homeassistant/components/xiaomi_ble/coordinator.py index 69fc427013a..a07b7fde3b1 100644 --- a/homeassistant/components/xiaomi_ble/coordinator.py +++ b/homeassistant/components/xiaomi_ble/coordinator.py @@ -67,7 +67,7 @@ class XiaomiActiveBluetoothProcessorCoordinator( @property def sleepy_device(self) -> bool: """Return True if the device is a sleepy device.""" - return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) + return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) # type: ignore[no-any-return] class XiaomiPassiveBluetoothDataProcessor[_T]( diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 0e28a2900bb..8db5273174b 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -466,8 +466,6 @@ async def async_setup_gateway_entry( await hass.config_entries.async_forward_entry_setups(entry, GATEWAY_PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - async def async_setup_device_entry( hass: HomeAssistant, entry: XiaomiMiioConfigEntry @@ -481,8 +479,6 @@ async def async_setup_device_entry( await hass.config_entries.async_forward_entry_setups(entry, platforms) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True @@ -493,10 +489,3 @@ async def async_unload_entry( platforms = get_platforms(config_entry) return await hass.config_entries.async_unload_platforms(config_entry, platforms) - - -async def update_listener( - hass: HomeAssistant, config_entry: XiaomiMiioConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index b8d8b028006..95eabb0188c 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -11,7 +11,11 @@ from micloud import MiCloud from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -56,7 +60,7 @@ DEVICE_CLOUD_CONFIG = vol.Schema( ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index d67e136be4a..5c481719cc9 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -22,16 +22,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: YaleConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 1aaad2aa63a..d8c1fc80f8f 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback @@ -171,7 +171,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): ) -class YaleOptionsFlowHandler(OptionsFlow): +class YaleOptionsFlowHandler(OptionsFlowWithReload): """Handle Yale options.""" async def async_step_init( diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 0b3ceaf2aee..cb24edae1fd 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -232,9 +232,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Wait to install the reload listener until everything was successfully initialized - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - return True @@ -245,11 +242,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def _async_get_device( hass: HomeAssistant, host: str, entry: ConfigEntry ) -> YeelightDevice: diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 15975ba22bd..cc3ab35f684 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback @@ -298,7 +298,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): return MODEL_UNKNOWN -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Yeelight.""" async def async_step_init( diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 07970cb25ca..d65ebb3a25a 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.44.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.45.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json index a1a71f6712e..56b0f0fdd3a 100644 --- a/homeassistant/components/youtube/manifest.json +++ b/homeassistant/components/youtube/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/youtube", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["youtubeaio==1.1.5"] + "requirements": ["youtubeaio==2.0.0"] } diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 93d585d72a2..4c9ef784077 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.65.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.66.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index ac65b9e2749..2efb8c8e67c 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -421,7 +421,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_0.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -429,7 +429,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_0.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -438,7 +438,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_1.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -446,7 +446,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_1.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -455,7 +455,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_2.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -463,13 +463,30 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_2.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, convert=convert_nested_attr, ), + ZWaveJSStatisticsSensorEntityDescription( + key="background_rssi.channel_3.average", + translation_key="avg_signal_noise", + translation_placeholders={"channel": "3"}, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + convert=convert_nested_attr, + ), + ZWaveJSStatisticsSensorEntityDescription( + key="background_rssi.channel_3.current", + translation_key="signal_noise", + translation_placeholders={"channel": "3"}, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + convert=convert_nested_attr, + ), ] CONTROLLER_STATISTICS_KEY_MAP: dict[str, str] = { @@ -488,6 +505,8 @@ CONTROLLER_STATISTICS_KEY_MAP: dict[str, str] = { "background_rssi.channel_1.current": "backgroundRSSI.channel1.current", "background_rssi.channel_2.average": "backgroundRSSI.channel2.average", "background_rssi.channel_2.current": "backgroundRSSI.channel2.current", + "background_rssi.channel_3.average": "backgroundRSSI.channel3.average", + "background_rssi.channel_3.current": "backgroundRSSI.channel3.current", } # Node statistics descriptions @@ -530,7 +549,7 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="rssi", - translation_key="rssi", + translation_key="signal_strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -539,7 +558,7 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ key="last_seen", translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, + entity_registry_enabled_default=False, ), ] diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 63dad248246..7f59e640ef8 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -199,8 +199,8 @@ } }, "sensor": { - "average_background_rssi": { - "name": "Average background RSSI (channel {channel})" + "avg_signal_noise": { + "name": "Avg. signal noise (channel {channel})" }, "can": { "name": "Collisions" @@ -216,9 +216,6 @@ "unresponsive": "Unresponsive" } }, - "current_background_rssi": { - "name": "Current background RSSI (channel {channel})" - }, "last_seen": { "name": "Last seen" }, @@ -238,12 +235,15 @@ "unknown": "Unknown" } }, - "rssi": { - "name": "RSSI" - }, "rtt": { "name": "Round trip time" }, + "signal_noise": { + "name": "Signal noise (channel {channel})" + }, + "signal_strength": { + "name": "Signal strength" + }, "successful_commands": { "name": "Successful commands ({direction})" }, diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 8d0ccf60fdf..52c24055052 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable import functools -from pydantic.v1 import ValidationError +from pydantic import ValidationError import voluptuous as vol from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP, Driver @@ -78,7 +78,7 @@ def validate_event_data(obj: dict) -> dict: except ValidationError as exc: # Filter out required field errors if keys can be missing, and if there are # still errors, raise an exception - if [error for error in exc.errors() if error["type"] != "value_error.missing"]: + if [error for error in exc.errors() if error["type"] != "missing"]: raise vol.MultipleInvalid from exc return obj diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e76b7ae099f..1c4f2b51ac7 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3491,7 +3491,22 @@ class OptionsFlowManager( entry = self.hass.config_entries.async_get_known_entry(flow.handler) if result["data"] is not None: - self.hass.config_entries.async_update_entry(entry, options=result["data"]) + automatic_reload = False + if isinstance(flow, OptionsFlowWithReload): + automatic_reload = flow.automatic_reload + + if automatic_reload and entry.update_listeners: + raise ValueError( + "Config entry update listeners should not be used with OptionsFlowWithReload" + ) + + if ( + self.hass.config_entries.async_update_entry( + entry, options=result["data"] + ) + and automatic_reload is True + ): + self.hass.config_entries.async_schedule_reload(entry.entry_id) result["result"] = True return result @@ -3600,6 +3615,18 @@ class OptionsFlowWithConfigEntry(OptionsFlow): return self._options +class OptionsFlowWithReload(OptionsFlow): + """Automatic reloading class for config options flows. + + Triggers an automatic reload of the config entry when the flow ends with + calling `async_create_entry` with changed options. + It's not allowed to use this class if the integration uses config entry + update listeners. + """ + + automatic_reload: bool = True + + class EntityRegistryDisabledHandler: """Handler when entities related to config entries updated disabled_by.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index 6b4f16c316f..2daa6d91db2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -245,6 +245,7 @@ CONF_PLATFORM: Final = "platform" CONF_PORT: Final = "port" CONF_PREFIX: Final = "prefix" CONF_PROFILE_NAME: Final = "profile_name" +CONF_PROMPT: Final = "prompt" CONF_PROTOCOL: Final = "protocol" CONF_PROXY_SSL: Final = "proxy_ssl" CONF_QUOTE: Final = "quote" diff --git a/homeassistant/core.py b/homeassistant/core.py index 8ffabf56171..299a7d32306 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -157,7 +157,6 @@ class EventStateEventData(TypedDict): """Base class for EVENT_STATE_CHANGED and EVENT_STATE_REPORTED data.""" entity_id: str - new_state: State | None class EventStateChangedData(EventStateEventData): @@ -166,6 +165,7 @@ class EventStateChangedData(EventStateEventData): A state changed event is fired when on state write the state is changed. """ + new_state: State | None old_state: State | None @@ -175,6 +175,8 @@ class EventStateReportedData(EventStateEventData): A state reported event is fired when on state write the state is unchanged. """ + last_reported: datetime.datetime + new_state: State old_last_reported: datetime.datetime @@ -1749,18 +1751,38 @@ class CompressedState(TypedDict): class State: - """Object to represent a state within the state machine. + """Object to represent a state within the state machine.""" - entity_id: the entity that is represented. - state: the state of the entity - attributes: extra information on entity and state - last_changed: last time the state was changed. - last_reported: last time the state was reported. - last_updated: last time the state or attributes were changed. - context: Context in which it was created - domain: Domain of this state. - object_id: Object id of this state. + entity_id: str + """The entity that is represented by the state.""" + domain: str + """Domain of the entity that is represented by the state.""" + object_id: str + """object_id: Object id of this state.""" + state: str + """The state of the entity.""" + attributes: ReadOnlyDict[str, Any] + """Extra information on entity and state""" + last_changed: datetime.datetime + """Last time the state was changed.""" + last_reported: datetime.datetime + """Last time the state was reported. + + Note: When the state is set and neither the state nor attributes are + changed, the existing state will be mutated with an updated last_reported. + + When handling a state change event, the last_reported attribute of the old + state will not be modified and can safely be used. The last_reported attribute + of the new state may be modified and the last_updated attribute should be used + instead. + + When handling a state report event, the last_reported attribute may be + modified and last_reported from the event data should be used instead. """ + last_updated: datetime.datetime + """Last time the state or attributes were changed.""" + context: Context + """Context in which the state was created.""" __slots__ = ( "_cache", @@ -1841,7 +1863,20 @@ class State: @under_cached_property def last_reported_timestamp(self) -> float: - """Timestamp of last report.""" + """Timestamp of last report. + + Note: When the state is set and neither the state nor attributes are + changed, the existing state will be mutated with an updated last_reported. + + When handling a state change event, the last_reported_timestamp attribute + of the old state will not be modified and can safely be used. The + last_reported_timestamp attribute of the new state may be modified and the + last_updated_timestamp attribute should be used instead. + + When handling a state report event, the last_reported_timestamp attribute may + be modified and last_reported from the event data should be used instead. + """ + return self.last_reported.timestamp() @under_cached_property @@ -2340,6 +2375,7 @@ class StateMachine: EVENT_STATE_REPORTED, { "entity_id": entity_id, + "last_reported": now, "old_last_reported": old_last_reported, "new_state": old_state, }, diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 92319af9617..49695b695ac 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -449,6 +449,7 @@ FLOWS = { "onkyo", "onvif", "open_meteo", + "open_router", "openai_conversation", "openexchangerates", "opengarage", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 277400bec02..8782d5c84b4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -665,6 +665,11 @@ "config_flow": true, "iot_class": "local_push" }, + "bauknecht": { + "name": "Bauknecht", + "integration_type": "virtual", + "supported_by": "whirlpool" + }, "bbox": { "name": "Bbox", "integration_type": "hub", @@ -4621,6 +4626,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "open_router": { + "name": "OpenRouter", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "openai_conversation": { "name": "OpenAI Conversation", "integration_type": "service", diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index f2dfb7250f7..39cff22396a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -402,7 +402,7 @@ def _async_track_state_change_event( _KEYED_TRACK_STATE_REPORT = _KeyedEventTracker( key=_TRACK_STATE_REPORT_DATA, event_type=EVENT_STATE_REPORTED, - dispatcher_callable=_async_dispatch_entity_id_event, + dispatcher_callable=_async_dispatch_entity_id_event_soon, filter_callable=_async_state_filter, ) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 784288375e9..1ff6b188214 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -315,10 +315,23 @@ class IntentTool(Tool): assistant=llm_context.assistant, device_id=llm_context.device_id, ) - response = intent_response.as_dict() - del response["language"] - del response["card"] - return response + return IntentResponseDict(intent_response) + + +class IntentResponseDict(dict): + """Dictionary to represent an intent response resulting from a tool call.""" + + def __init__(self, intent_response: Any) -> None: + """Initialize the dictionary.""" + if not isinstance(intent_response, intent.IntentResponse): + super().__init__(intent_response) + return + + result = intent_response.as_dict() + del result["language"] + del result["card"] + super().__init__(result) + self.original = intent_response class NamespacedTool(Tool): diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index bc24113251c..2429b4b23e8 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -117,11 +117,8 @@ def _validate_supported_feature(supported_feature: str) -> int: raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc -def _validate_supported_features(supported_features: int | list[str]) -> int: - """Validate a supported feature and resolve an enum string to its value.""" - - if isinstance(supported_features, int): - return supported_features +def _validate_supported_features(supported_features: list[str]) -> int: + """Validate supported features and resolve enum strings to their value.""" feature_mask = 0 @@ -160,6 +157,22 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( ) +# Legacy entity selector config schema used directly under entity selectors +# is provided for backwards compatibility and remains feature frozen. +# New filtering features should be added under the `filter` key instead. +# https://github.com/home-assistant/frontend/pull/15302 +LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration that provided the entity + vol.Optional("integration"): str, + # Domain the entity belongs to + vol.Optional("domain"): vol.All(cv.ensure_list, [str]), + # Device class of the entity + vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), + } +) + + class EntityFilterSelectorConfig(TypedDict, total=False): """Class to represent a single entity selector config.""" @@ -179,10 +192,22 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("model"): str, # Model ID of device vol.Optional("model_id"): str, - # Device has to contain entities matching this selector - vol.Optional("entity"): vol.All( - cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] - ), + } +) + + +# Legacy device selector config schema used directly under device selectors +# is provided for backwards compatibility and remains feature frozen. +# New filtering features should be added under the `filter` key instead. +# https://github.com/home-assistant/frontend/pull/15302 +LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration linked to it with a config entry + vol.Optional("integration"): str, + # Manufacturer of device + vol.Optional("manufacturer"): str, + # Model of device + vol.Optional("model"): str, } ) @@ -714,9 +739,13 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.schema + LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA.schema ).extend( { + # Device has to contain entities matching this selector + vol.Optional("entity"): vol.All( + cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] + ), vol.Optional("multiple", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, @@ -784,6 +813,7 @@ class EntitySelectorConfig(BaseSelectorConfig, EntityFilterSelectorConfig, total exclude_entities: list[str] include_entities: list[str] multiple: bool + reorder: bool filter: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] @@ -794,12 +824,13 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.schema + LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA.schema ).extend( { vol.Optional("exclude_entities"): [str], vol.Optional("include_entities"): [str], vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("reorder", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], @@ -1079,10 +1110,12 @@ class NumberSelectorMode(StrEnum): def validate_slider(data: Any) -> Any: """Validate configuration.""" - if data["mode"] == "box": - return data + has_min_max = "min" in data and "max" in data - if "min" not in data or "max" not in data: + if "mode" not in data: + data["mode"] = "slider" if has_min_max else "box" + + if data["mode"] == "slider" and not has_min_max: raise vol.Invalid("min and max are required in slider mode") return data @@ -1105,7 +1138,7 @@ class NumberSelector(Selector[NumberSelectorConfig]): "any", vol.All(vol.Coerce(float), vol.Range(min=1e-3)) ), vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, - vol.Optional(CONF_MODE, default=NumberSelectorMode.SLIDER): vol.All( + vol.Optional(CONF_MODE): vol.All( vol.Coerce(NumberSelectorMode), lambda val: val.value ), vol.Optional("translation_key"): str, @@ -1305,7 +1338,8 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): class StateSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an state selector config.""" - entity_id: Required[str] + entity_id: str + hide_states: list[str] @SELECTORS.register("state") @@ -1316,7 +1350,8 @@ class StateSelector(Selector[StateSelectorConfig]): CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { - vol.Required("entity_id"): cv.entity_id, + vol.Optional("entity_id"): cv.entity_id, + vol.Optional("hide_states"): [str], # The attribute to filter on, is currently deliberately not # configurable/exposed. We are considering separating state # selectors into two types: one for state and one for attribute. diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3186c211eaa..f9c846c60fa 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ACTION, CONF_ENTITY_ID, + CONF_SELECTOR, CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE, CONF_SERVICE_TEMPLATE, @@ -54,6 +55,7 @@ from . import ( config_validation as cv, device_registry, entity_registry, + selector, target as target_helpers, template, translation, @@ -166,6 +168,7 @@ def validate_supported_feature(supported_feature: str) -> Any: # to their values. Full validation is done by hassfest.services _FIELD_SCHEMA = vol.Schema( { + vol.Optional(CONF_SELECTOR): selector.validate_selector, vol.Optional("filter"): { vol.Optional("attribute"): { vol.Required(str): [vol.All(str, validate_attribute_option)], diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index bf7598eb024..d8ebab8b83e 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -13,8 +13,10 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA, + SensorDeviceClass, SensorEntity, ) +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, @@ -389,3 +391,20 @@ class ManualTriggerSensorEntity(ManualTriggerEntity, SensorEntity): ManualTriggerEntity.__init__(self, hass, config) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = config.get(CONF_STATE_CLASS) + + @callback + def _set_native_value_with_possible_timestamp(self, value: Any) -> None: + """Set native value with possible timestamp. + + If self.device_class is `date` or `timestamp`, + it will try to parse the value to a date/datetime object. + """ + if self.device_class not in ( + SensorDeviceClass.DATE, + SensorDeviceClass.TIMESTAMP, + ): + self._attr_native_value = value + elif value is not None: + self._attr_native_value = async_parse_date_datetime( + value, self.entity_id, self.device_class + ) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index bd85391f98f..6b566797017 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -84,9 +84,19 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.update_interval = update_interval self._shutdown_requested = False if config_entry is UNDEFINED: + # late import to avoid circular imports + from . import frame # noqa: PLC0415 + + # It is not planned to enforce this for custom integrations. + # see https://github.com/home-assistant/core/pull/138161#discussion_r1958184241 + frame.report_usage( + "relies on ContextVar, but should pass the config entry explicitly.", + core_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.LOG, + breaks_in_ha_version="2026.8", + ) + self.config_entry = config_entries.current_entry.get() - # This should be deprecated once all core integrations are updated - # to pass in the config entry explicitly. else: self.config_entry = config_entry self.always_update = always_update diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 1e338be0a0f..07c4a934573 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -10,7 +10,6 @@ import asyncio from collections.abc import Callable, Iterable from contextlib import suppress from dataclasses import dataclass -import functools as ft import importlib import logging import os @@ -1650,77 +1649,6 @@ class CircularDependency(LoaderError): self.args[1].insert(0, domain) -def _load_file( - hass: HomeAssistant, comp_or_platform: str, base_paths: list[str] -) -> ComponentProtocol | None: - """Try to load specified file. - - Looks in config dir first, then built-in components. - Only returns it if also found to be valid. - Async friendly. - """ - cache = hass.data[DATA_COMPONENTS] - if module := cache.get(comp_or_platform): - return cast(ComponentProtocol, module) - - for path in (f"{base}.{comp_or_platform}" for base in base_paths): - try: - module = importlib.import_module(path) - - # In Python 3 you can import files from directories that do not - # contain the file __init__.py. A directory is a valid module if - # it contains a file with the .py extension. In this case Python - # will succeed in importing the directory as a module and call it - # a namespace. We do not care about namespaces. - # This prevents that when only - # custom_components/switch/some_platform.py exists, - # the import custom_components.switch would succeed. - # __file__ was unset for namespaces before Python 3.7 - if getattr(module, "__file__", None) is None: - continue - - cache[comp_or_platform] = module - - return cast(ComponentProtocol, module) - - except ImportError as err: - # This error happens if for example custom_components/switch - # exists and we try to load switch.demo. - # Ignore errors for custom_components, custom_components.switch - # and custom_components.switch.demo. - white_listed_errors = [] - parts = [] - for part in path.split("."): - parts.append(part) - white_listed_errors.append(f"No module named '{'.'.join(parts)}'") - - if str(err) not in white_listed_errors: - _LOGGER.exception( - "Error loading %s. Make sure all dependencies are installed", path - ) - - return None - - -class ModuleWrapper: - """Class to wrap a Python module and auto fill in hass argument.""" - - def __init__(self, hass: HomeAssistant, module: ComponentProtocol) -> None: - """Initialize the module wrapper.""" - self._hass = hass - self._module = module - - def __getattr__(self, attr: str) -> Any: - """Fetch an attribute.""" - value = getattr(self._module, attr) - - if hasattr(value, "__bind_hass"): - value = ft.partial(value, self._hass) - - setattr(self, attr, value) - return value - - def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: """Decorate function to indicate that first argument is hass. @@ -1744,13 +1672,6 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: sys.path_importer_cache.pop(hass.config.config_dir, None) -def _lookup_path(hass: HomeAssistant) -> list[str]: - """Return the lookup paths for legacy lookups.""" - if hass.config.recovery_mode or hass.config.safe_mode: - return [PACKAGE_BUILTIN] - return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] - - def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: """Test if a component module is loaded.""" return module in hass.data[DATA_COMPONENTS] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9e21c5830e4..aa0e1768d52 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 atomicwrites-homeassistant==1.4.1 attrs==25.3.0 audioop-lts==0.2.1 @@ -35,17 +35,17 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.1 -hass-nabucasa==0.106.0 +hass-nabucasa==0.108.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.18 +orjson==3.11.0 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.3.0 @@ -150,12 +150,8 @@ protobuf==6.31.1 # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 13.1 is the first version to fully support the new -# asyncio implementation. The legacy implementation is now -# deprecated as of websockets 14.0. -# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features -# https://websockets.readthedocs.io/en/stable/howto/upgrade.html -websockets>=13.1 +# Prevent accidental fallbacks +websockets>=15.0.1 # pysnmplib is no longer maintained and does not work with newer # python diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 80ced039e46..8e232498177 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -65,6 +65,7 @@ "path": "Path", "pin": "PIN code", "port": "Port", + "prompt": "Instructions", "ssl": "Uses an SSL certificate", "url": "URL", "usb_path": "USB device path", diff --git a/mypy.ini b/mypy.ini index 25039f7f386..bff6c93967e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3526,6 +3526,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.open_router.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.openai_conversation.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 860b4af379d..b1b43c80cd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.106.0", + "hass-nabucasa==0.108.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", @@ -61,7 +61,7 @@ dependencies = [ "Pillow==11.3.0", "propcache==0.3.2", "pyOpenSSL==25.1.0", - "orjson==3.10.18", + "orjson==3.11.0", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", @@ -589,10 +589,6 @@ filterwarnings = [ # -- Websockets 14.1 # https://websockets.readthedocs.io/en/stable/howto/upgrade.html "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", - # https://github.com/bluecurrent/HomeAssistantAPI/pull/19 - >=1.2.4 - "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket", - "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket", - "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket", # https://github.com/graphql-python/gql/pull/543 - >=4.0.0a0 "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", diff --git a/requirements.txt b/requirements.txt index 118d2bedfa6..e4065bed83e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.106.0 +hass-nabucasa==0.108.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 @@ -33,7 +33,7 @@ cryptography==45.0.3 Pillow==11.3.0 propcache==0.3.2 pyOpenSSL==25.1.0 -orjson==3.10.18 +orjson==3.11.0 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 diff --git a/requirements_all.txt b/requirements_all.txt index 8fe43a3198c..60e64a1ad27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.68.1 +PySwitchbot==0.68.2 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -179,13 +179,13 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.13 +aioairzone-cloud==0.6.15 # homeassistant.components.airzone aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.10 +aioamazondevices==3.5.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==1.2.2 +aioautomower==2.0.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==35.0.0 +aioesphomeapi==37.0.2 # homeassistant.components.flo aioflo==2021.11.0 @@ -513,7 +513,7 @@ aqualogic==2.6 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.1 +arcam-fmj==1.8.2 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 @@ -527,7 +527,7 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -634,7 +634,7 @@ blinkpy==0.23.0 blockchain==1.4.4 # homeassistant.components.blue_current -bluecurrent-api==1.2.3 +bluecurrent-api==1.2.4 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 @@ -1020,7 +1020,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.1 +gios==6.1.2 # homeassistant.components.gitter gitterpy==0.1.7 @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.106.0 +hass-nabucasa==0.108.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1168,13 +1168,13 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 # homeassistant.components.conversation home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.7 +homematicip==2.2.0 # homeassistant.components.horizon horimote==0.4.1 @@ -1234,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.2.0 +imgw_pib==1.4.2 # homeassistant.components.incomfort incomfort-client==0.6.9 @@ -1334,7 +1334,7 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.4.0 +letpot==0.5.0 # homeassistant.components.foscam libpyfoscamcgi==0.0.6 @@ -1596,6 +1596,7 @@ open-garage==0.2.0 # homeassistant.components.open_meteo open-meteo==0.3.2 +# homeassistant.components.open_router # homeassistant.components.openai_conversation openai==1.93.3 @@ -1962,7 +1963,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.1 +pyenphase==2.2.2 # homeassistant.components.envisalink pyenvisalink==4.7 @@ -2309,7 +2310,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2025.4.0 +pyschlage==2025.7.3 # homeassistant.components.sensibo pysensibo==1.2.1 @@ -2345,7 +2346,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.0 +pysmarlaapi==0.9.1 # homeassistant.components.smartthings pysmartthings==3.2.8 @@ -2476,6 +2477,9 @@ python-mpd2==3.1.1 # homeassistant.components.mystrom python-mystrom==2.4.0 +# homeassistant.components.open_router +python-open-router==0.3.0 + # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 @@ -2526,7 +2530,7 @@ python-vlc==3.0.18122 pythonegardia==1.0.52 # homeassistant.components.uptime_kuma -pythonkuma==0.3.0 +pythonkuma==0.3.1 # homeassistant.components.tile pytile==2024.12.0 @@ -2997,7 +3001,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.2 +uiprotect==7.16.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -3172,7 +3176,7 @@ yolink-api==0.5.7 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==1.1.5 +youtubeaio==2.0.0 # homeassistant.components.media_extractor yt-dlp[default]==2025.06.09 @@ -3205,7 +3209,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.65.0 +zwave-js-server-python==0.66.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index 386e380911a..b0affc56113 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,13 +13,13 @@ freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.17.0a4 +mypy-dev==1.18.0a2 pre-commit==4.2.0 pydantic==2.11.7 pylint==3.3.7 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 -pytest-asyncio==1.0.0 +pytest-asyncio==1.1.0 pytest-aiohttp==1.1.0 pytest-cov==6.2.1 pytest-freezer==0.4.9 @@ -35,17 +35,17 @@ requests-mock==1.12.1 respx==0.22.0 syrupy==4.9.1 tqdm==4.67.1 -types-aiofiles==24.1.0.20250606 +types-aiofiles==24.1.0.20250708 types-atomicwrites==1.4.5.1 -types-croniter==6.0.0.20250411 +types-croniter==6.0.0.20250626 types-caldav==1.3.0.20250516 types-chardet==0.1.5 types-decorator==5.2.0.20250324 types-pexpect==4.9.0.20250516 -types-protobuf==6.30.2.20250516 +types-protobuf==6.30.2.20250703 types-psutil==7.0.0.20250601 types-pyserial==3.5.0.20250326 -types-python-dateutil==2.9.0.20250516 +types-python-dateutil==2.9.0.20250708 types-python-slugify==8.0.2.20240310 types-pytz==2025.2.0.20250516 types-PyYAML==6.0.12.20250516 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7e3da48a19..20c826b73e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.68.1 +PySwitchbot==0.68.2 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -167,13 +167,13 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.13 +aioairzone-cloud==0.6.15 # homeassistant.components.airzone aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.10 +aioamazondevices==3.5.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==1.2.2 +aioautomower==2.0.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==35.0.0 +aioesphomeapi==37.0.2 # homeassistant.components.flo aioflo==2021.11.0 @@ -483,7 +483,7 @@ apsystems-ez1==2.7.0 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.1 +arcam-fmj==1.8.2 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms @@ -491,7 +491,7 @@ arcam-fmj==1.8.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -565,7 +565,7 @@ blebox-uniapi==2.5.0 blinkpy==0.23.0 # homeassistant.components.blue_current -bluecurrent-api==1.2.3 +bluecurrent-api==1.2.4 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 @@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.1 +gios==6.1.2 # homeassistant.components.glances glances-api==0.8.0 @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.106.0 +hass-nabucasa==0.108.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation @@ -1017,13 +1017,13 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 # homeassistant.components.conversation home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.7 +homematicip==2.2.0 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -1068,7 +1068,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.2.0 +imgw_pib==1.4.2 # homeassistant.components.incomfort incomfort-client==0.6.9 @@ -1153,7 +1153,7 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.4.0 +letpot==0.5.0 # homeassistant.components.foscam libpyfoscamcgi==0.0.6 @@ -1364,6 +1364,7 @@ open-garage==0.2.0 # homeassistant.components.open_meteo open-meteo==0.3.2 +# homeassistant.components.open_router # homeassistant.components.openai_conversation openai==1.93.3 @@ -1637,7 +1638,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.1 +pyenphase==2.2.2 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1921,7 +1922,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2025.4.0 +pyschlage==2025.7.3 # homeassistant.components.sensibo pysensibo==1.2.1 @@ -1948,7 +1949,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.0 +pysmarlaapi==0.9.1 # homeassistant.components.smartthings pysmartthings==3.2.8 @@ -2049,6 +2050,9 @@ python-mpd2==3.1.1 # homeassistant.components.mystrom python-mystrom==2.4.0 +# homeassistant.components.open_router +python-open-router==0.3.0 + # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 @@ -2090,7 +2094,7 @@ python-technove==2.0.0 python-telegram-bot[socks]==21.5 # homeassistant.components.uptime_kuma -pythonkuma==0.3.0 +pythonkuma==0.3.1 # homeassistant.components.tile pytile==2024.12.0 @@ -2471,7 +2475,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.2 +uiprotect==7.16.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2619,7 +2623,7 @@ yolink-api==0.5.7 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==1.1.5 +youtubeaio==2.0.0 # homeassistant.components.media_extractor yt-dlp[default]==2025.06.09 @@ -2640,7 +2644,7 @@ zeversolar==0.3.2 zha==0.0.62 # homeassistant.components.zwave_js -zwave-js-server-python==0.65.0 +zwave-js-server-python==0.66.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 005d97175a7..b45d48aeff4 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -176,12 +176,8 @@ protobuf==6.31.1 # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 13.1 is the first version to fully support the new -# asyncio implementation. The legacy implementation is now -# deprecated as of websockets 14.0. -# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features -# https://websockets.readthedocs.io/en/stable/howto/upgrade.html -websockets>=13.1 +# Prevent accidental fallbacks +websockets>=15.0.1 # pysnmplib is no longer maintained and does not work with newer # python diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index b5fd8c3ad7a..3008c6303ff 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1162,7 +1162,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "aftership", "agent_dvr", "airly", - "airgradient", "airnow", "airq", "airthings", diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 70f0a63ca76..84d3aaefa88 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -43,104 +43,117 @@ def unique_field_validator(fields: Any) -> Any: return fields -CORE_INTEGRATION_FIELD_SCHEMA = vol.Schema( - { - vol.Optional("example"): exists, - vol.Optional("default"): exists, - vol.Optional("required"): bool, - vol.Optional("advanced"): bool, - vol.Optional(CONF_SELECTOR): selector.validate_selector, - vol.Optional("filter"): { - vol.Exclusive("attribute", "field_filter"): { - vol.Required(str): [vol.All(str, service.validate_attribute_option)], - }, - vol.Exclusive("supported_features", "field_filter"): [ - vol.All(str, service.validate_supported_feature) - ], +CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT = { + vol.Optional("description"): str, + vol.Optional("name"): str, +} + + +CORE_INTEGRATION_NOT_TARGETED_FIELD_SCHEMA_DICT = { + vol.Optional("example"): exists, + vol.Optional("default"): exists, + vol.Optional("required"): bool, + vol.Optional("advanced"): bool, + vol.Optional(CONF_SELECTOR): selector.validate_selector, +} + +FIELD_FILTER_SCHEMA_DICT = { + vol.Optional("filter"): { + vol.Exclusive("attribute", "field_filter"): { + vol.Required(str): [vol.All(str, service.validate_attribute_option)], }, + vol.Exclusive("supported_features", "field_filter"): [ + vol.All(str, service.validate_supported_feature) + ], } -) +} -CORE_INTEGRATION_SECTION_SCHEMA = vol.Schema( - { + +def _field_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the field schema.""" + schema_dict = CORE_INTEGRATION_NOT_TARGETED_FIELD_SCHEMA_DICT.copy() + + # Filters are only allowed for targeted services because they rely on the presence + # of a `target` field to determine the scope of the service call. Non-targeted + # services do not have a `target` field, making filters inapplicable. + if targeted: + schema_dict |= FIELD_FILTER_SCHEMA_DICT + + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT + + return vol.Schema(schema_dict) + + +def _section_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the section schema.""" + schema_dict = { vol.Optional("collapsed"): bool, - vol.Required("fields"): vol.Schema({str: CORE_INTEGRATION_FIELD_SCHEMA}), + vol.Required("fields"): vol.Schema( + { + str: _field_schema(targeted, custom), + } + ), } -) -CUSTOM_INTEGRATION_FIELD_SCHEMA = CORE_INTEGRATION_FIELD_SCHEMA.extend( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - } -) + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT -CUSTOM_INTEGRATION_SECTION_SCHEMA = vol.Schema( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - vol.Optional("collapsed"): bool, - vol.Required("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), + return vol.Schema(schema_dict) + + +def _service_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the service schema.""" + schema_dict = { + vol.Optional("fields"): vol.All( + vol.Schema( + { + str: vol.Any( + _field_schema(targeted, custom), + _section_schema(targeted, custom), + ), + } + ), + unique_field_validator, + ) } -) + + if targeted: + schema_dict[vol.Required("target")] = vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ) + + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT + + return vol.Schema(schema_dict) CORE_INTEGRATION_SERVICE_SCHEMA = vol.Any( - vol.Schema( - { - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None - ), - vol.Optional("fields"): vol.All( - vol.Schema( - { - str: vol.Any( - CORE_INTEGRATION_FIELD_SCHEMA, - CORE_INTEGRATION_SECTION_SCHEMA, - ) - } - ), - unique_field_validator, - ), - } - ), + _service_schema(targeted=True, custom=False), + _service_schema(targeted=False, custom=False), None, ) CUSTOM_INTEGRATION_SERVICE_SCHEMA = vol.Any( - vol.Schema( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None - ), - vol.Optional("fields"): vol.All( - vol.Schema( - { - str: vol.Any( - CUSTOM_INTEGRATION_FIELD_SCHEMA, - CUSTOM_INTEGRATION_SECTION_SCHEMA, - ) - } - ), - unique_field_validator, - ), - } - ), + _service_schema(targeted=True, custom=True), + _service_schema(targeted=False, custom=True), None, ) + CORE_INTEGRATION_SERVICES_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, service.starts_with_dot)): object, cv.slug: CORE_INTEGRATION_SERVICE_SCHEMA, } ) + CUSTOM_INTEGRATION_SERVICES_SCHEMA = vol.Schema( {cv.slug: CUSTOM_INTEGRATION_SERVICE_SCHEMA} ) + VALIDATE_AS_CUSTOM_INTEGRATION = { # Adding translations would be a breaking change "foursquare", diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index ff6654f2789..8efaab47050 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -38,6 +38,9 @@ FIELD_SCHEMA = vol.Schema( TRIGGER_SCHEMA = vol.Any( vol.Schema( { + vol.Optional("target"): vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ), vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), } ), diff --git a/script/translations/const.py b/script/translations/const.py index 9ff8aeb2d70..18aa27b3e74 100644 --- a/script/translations/const.py +++ b/script/translations/const.py @@ -4,6 +4,6 @@ import pathlib CORE_PROJECT_ID = "130246255a974bd3b5e8a1.51616605" FRONTEND_PROJECT_ID = "3420425759f6d6d241f598.13594006" -CLI_2_DOCKER_IMAGE = "v2.6.8" +CLI_2_DOCKER_IMAGE = "v2.6.14" INTEGRATIONS_DIR = pathlib.Path("homeassistant/components") FRONTEND_DIR = pathlib.Path("../frontend") diff --git a/script/translations/download.py b/script/translations/download.py index 3fa7065d058..0c9504f44cd 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -20,7 +20,7 @@ DOWNLOAD_DIR = Path("build/translations-download").absolute() def run_download_docker(): """Run the Docker image to download the translations.""" print("Running Docker to download latest translations.") - run = subprocess.run( + result = subprocess.run( [ "docker", "run", @@ -52,7 +52,7 @@ def run_download_docker(): ) print() - if run.returncode != 0: + if result.returncode != 0: raise ExitApp("Failed to download translations") diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 4bd7bfaccdd..3d566e6297b 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -210,10 +210,35 @@ 'ws-connected': True, }), }), + 'air-quality': dict({ + 'airqsensor1': dict({ + 'aq-active': False, + 'aq-index': 1, + 'aq-pm-1': 3, + 'aq-pm-10': 3, + 'aq-pm-2.5': 4, + 'aq-present': True, + 'aq-status': 'good', + 'available': True, + 'double-set-point': False, + 'id': 'airqsensor1', + 'installation': 'installation1', + 'is-connected': True, + 'name': 'CapteurQ', + 'problems': False, + 'system': 1, + 'web-server': 'webserver1', + 'ws-connected': True, + 'zone': 1, + }), + }), 'groups': dict({ 'group1': dict({ 'action': 1, 'active': True, + 'air-quality': list([ + 'airqsensor1', + ]), 'available': True, 'hot-water': list([ 'dhw1', @@ -332,6 +357,9 @@ 'aidoo1', 'aidoo_pro', ]), + 'air-quality': list([ + 'airqsensor1', + ]), 'available': True, 'groups': list([ 'group1', @@ -377,6 +405,7 @@ }), 'systems': dict({ 'system1': dict({ + 'aq-active': False, 'aq-index': 1, 'aq-pm-1': 3, 'aq-pm-10': 3, @@ -463,6 +492,7 @@ 'action': 1, 'active': True, 'air-demand': True, + 'air-quality-id': 'airqsensor1', 'aq-active': False, 'aq-index': 1, 'aq-mode-conf': 'auto', @@ -528,19 +558,12 @@ 'action': 6, 'active': False, 'air-demand': False, - 'aq-active': False, - 'aq-index': 1, 'aq-mode-conf': 'auto', 'aq-mode-values': list([ 'off', 'on', 'auto', ]), - 'aq-pm-1': 3, - 'aq-pm-10': 3, - 'aq-pm-2.5': 4, - 'aq-present': True, - 'aq-status': 'good', 'available': True, 'double-set-point': False, 'floor-demand': False, diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py index bb2d0f78060..d88f66e6b2c 100644 --- a/tests/components/airzone_cloud/test_binary_sensor.py +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -45,7 +45,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dormitorio_air_quality_active") - assert state.state == STATE_OFF + assert state is None state = hass.states.get("binary_sensor.dormitorio_battery") assert state.state == STATE_OFF diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index 672e10adedb..330a9efbef1 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -59,19 +59,19 @@ async def test_airzone_create_sensors(hass: HomeAssistant) -> None: # Zones state = hass.states.get("sensor.dormitorio_air_quality_index") - assert state.state == "1" + assert state is None state = hass.states.get("sensor.dormitorio_battery") assert state.state == "54" state = hass.states.get("sensor.dormitorio_pm1") - assert state.state == "3" + assert state is None state = hass.states.get("sensor.dormitorio_pm2_5") - assert state.state == "4" + assert state is None state = hass.states.get("sensor.dormitorio_pm10") - assert state.state == "3" + assert state is None state = hass.states.get("sensor.dormitorio_signal_percentage") assert state.state == "76" @@ -82,7 +82,7 @@ async def test_airzone_create_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.dormitorio_humidity") assert state.state == "24" - state = hass.states.get("sensor.dormitorio_air_quality_index") + state = hass.states.get("sensor.salon_air_quality_index") assert state.state == "1" state = hass.states.get("sensor.salon_pm1") diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 52b0ae0bec3..835011f8c8c 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -19,6 +19,7 @@ from aioairzone_cloud.const import ( API_AZ_ACS, API_AZ_AIDOO, API_AZ_AIDOO_PRO, + API_AZ_AIRQSENSOR, API_AZ_SYSTEM, API_AZ_ZONE, API_CELSIUS, @@ -170,6 +171,17 @@ GET_INSTALLATION_MOCK = { }, API_WS_ID: WS_ID, }, + { + API_CONFIG: { + API_SYSTEM_NUMBER: 1, + API_ZONE_NUMBER: 1, + }, + API_DEVICE_ID: "airqsensor1", + API_NAME: "CapteurQ", + API_TYPE: API_AZ_AIRQSENSOR, + API_META: {}, + API_WS_ID: WS_ID, + }, ], }, { @@ -394,11 +406,6 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "system1": return { API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_ERRORS: [ { API_OLD_ID: "error-id", @@ -419,14 +426,8 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: True, API_AIR_ACTIVE: True, - API_AQ_ACTIVE: False, API_AQ_MODE_CONF: "auto", API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_DOUBLE_SET_POINT: False, API_HUMIDITY: 30, API_MODE: OperationMode.COOLING.value, @@ -466,14 +467,8 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: False, API_AIR_ACTIVE: False, - API_AQ_ACTIVE: False, API_AQ_MODE_CONF: "auto", API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_DOUBLE_SET_POINT: False, API_HUMIDITY: 24, API_MODE: OperationMode.COOLING.value, @@ -504,6 +499,19 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_LOCAL_TEMP: {API_FAH: 77, API_CELSIUS: 25}, API_WARNINGS: [], } + if device.get_id() == "airqsensor1": + return { + API_AQ_ACTIVE: False, + API_AQ_MODE_CONF: "auto", + API_AQ_MODE_VALUES: ["off", "on", "auto"], + API_AQ_PM_1: 3, + API_AQ_PM_2P5: 4, + API_AQ_PM_10: 3, + API_AQ_PRESENT: True, + API_AQ_QUALITY: "good", + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + } return {} diff --git a/tests/components/amberelectric/__init__.py b/tests/components/amberelectric/__init__.py index 9eae18c65aa..8ee603cee14 100644 --- a/tests/components/amberelectric/__init__.py +++ b/tests/components/amberelectric/__init__.py @@ -1 +1,13 @@ """Tests for the amberelectric integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/amberelectric/conftest.py b/tests/components/amberelectric/conftest.py index ce4073db71b..57f93074883 100644 --- a/tests/components/amberelectric/conftest.py +++ b/tests/components/amberelectric/conftest.py @@ -1,10 +1,59 @@ """Provide common Amber fixtures.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, Mock, patch +from amberelectric.models.interval import Interval import pytest +from homeassistant.components.amberelectric.const import ( + CONF_SITE_ID, + CONF_SITE_NAME, + DOMAIN, +) +from homeassistant.const import CONF_API_TOKEN + +from .helpers import ( + CONTROLLED_LOAD_CHANNEL, + FEED_IN_CHANNEL, + FORECASTS, + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_CHANNEL, + GENERAL_CHANNEL_WITH_RANGE, + GENERAL_FORECASTS, + GENERAL_ONLY_SITE_ID, +) + +from tests.common import MockConfigEntry + +MOCK_API_TOKEN = "psk_0000000000000000" + + +def create_amber_config_entry( + site_id: str, entry_id: str, name: str +) -> MockConfigEntry: + """Create an Amber config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_NAME: name, + CONF_SITE_ID: site_id, + }, + entry_id=entry_id, + ) + + +@pytest.fixture +def mock_amber_client() -> Generator[AsyncMock]: + """Mock the Amber API client.""" + with patch( + "homeassistant.components.amberelectric.amberelectric.AmberApi", + autospec=True, + ) as mock_client: + yield mock_client + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +62,129 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.amberelectric.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +async def general_channel_config_entry(): + """Generate the default Amber config entry.""" + return create_amber_config_entry(GENERAL_ONLY_SITE_ID, GENERAL_ONLY_SITE_ID, "home") + + +@pytest.fixture +async def general_channel_and_controlled_load_config_entry(): + """Generate the default Amber config entry for site with controlled load.""" + return create_amber_config_entry( + GENERAL_AND_CONTROLLED_SITE_ID, GENERAL_AND_CONTROLLED_SITE_ID, "home" + ) + + +@pytest.fixture +async def general_channel_and_feed_in_config_entry(): + """Generate the default Amber config entry for site with feed in.""" + return create_amber_config_entry( + GENERAL_AND_FEED_IN_SITE_ID, GENERAL_AND_FEED_IN_SITE_ID, "home" + ) + + +@pytest.fixture +def general_channel_prices() -> list[Interval]: + """List containing general channel prices.""" + return GENERAL_CHANNEL + + +@pytest.fixture +def general_channel_prices_with_range() -> list[Interval]: + """List containing general channel prices.""" + return GENERAL_CHANNEL_WITH_RANGE + + +@pytest.fixture +def controlled_load_channel_prices() -> list[Interval]: + """List containing controlled load channel prices.""" + return CONTROLLED_LOAD_CHANNEL + + +@pytest.fixture +def feed_in_channel_prices() -> list[Interval]: + """List containing feed in channel prices.""" + return FEED_IN_CHANNEL + + +@pytest.fixture +def forecast_prices() -> list[Interval]: + """List containing forecasts with advanced prices.""" + return FORECASTS + + +@pytest.fixture +def general_forecast_prices() -> list[Interval]: + """List containing forecasts with advanced prices.""" + return GENERAL_FORECASTS + + +@pytest.fixture +def mock_amber_client_general_channel( + mock_amber_client: AsyncMock, general_channel_prices: list[Interval] +) -> Generator[AsyncMock]: + """Fake general channel prices.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_channel_prices + return mock_amber_client + + +@pytest.fixture +def mock_amber_client_general_channel_with_range( + mock_amber_client: AsyncMock, general_channel_prices_with_range: list[Interval] +) -> Generator[AsyncMock]: + """Fake general channel prices with a range.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_channel_prices_with_range + return mock_amber_client + + +@pytest.fixture +def mock_amber_client_general_and_controlled_load( + mock_amber_client: AsyncMock, + general_channel_prices: list[Interval], + controlled_load_channel_prices: list[Interval], +) -> Generator[AsyncMock]: + """Fake general channel and controlled load channel prices.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = ( + general_channel_prices + controlled_load_channel_prices + ) + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_general_and_feed_in( + mock_amber_client: AsyncMock, + general_channel_prices: list[Interval], + feed_in_channel_prices: list[Interval], +) -> AsyncGenerator[Mock]: + """Set up general channel and feed in channel.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = ( + general_channel_prices + feed_in_channel_prices + ) + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_forecasts( + mock_amber_client: AsyncMock, forecast_prices: list[Interval] +) -> AsyncGenerator[Mock]: + """Set up general channel, controlled load and feed in channel.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = forecast_prices + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_general_forecasts( + mock_amber_client: AsyncMock, general_forecast_prices: list[Interval] +) -> AsyncGenerator[Mock]: + """Set up general channel only.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_forecast_prices + return mock_amber_client diff --git a/tests/components/amberelectric/helpers.py b/tests/components/amberelectric/helpers.py index 971f3690a0d..d4f968f01d1 100644 --- a/tests/components/amberelectric/helpers.py +++ b/tests/components/amberelectric/helpers.py @@ -3,11 +3,13 @@ from datetime import datetime, timedelta from amberelectric.models.actual_interval import ActualInterval +from amberelectric.models.advanced_price import AdvancedPrice from amberelectric.models.channel import ChannelType from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.forecast_interval import ForecastInterval from amberelectric.models.interval import Interval from amberelectric.models.price_descriptor import PriceDescriptor +from amberelectric.models.range import Range from amberelectric.models.spike_status import SpikeStatus from dateutil import parser @@ -15,12 +17,16 @@ from dateutil import parser def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> Interval: """Generate a mock actual interval.""" start_time = end_time - timedelta(minutes=30) + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 return Interval( ActualInterval( type="ActualInterval", duration=30, spot_per_kwh=1.0, - per_kwh=8.0, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -34,16 +40,23 @@ def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> I def generate_current_interval( - channel_type: ChannelType, end_time: datetime + channel_type: ChannelType, + end_time: datetime, + range=False, ) -> Interval: """Generate a mock current price.""" start_time = end_time - timedelta(minutes=30) - return Interval( + per_kwh = 8.8 + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 + interval = Interval( CurrentInterval( type="CurrentInterval", duration=30, spot_per_kwh=1.0, - per_kwh=8.0, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -56,18 +69,28 @@ def generate_current_interval( ) ) + if range: + interval.actual_instance.range = Range(min=6.7, max=9.1) + + return interval + def generate_forecast_interval( - channel_type: ChannelType, end_time: datetime + channel_type: ChannelType, end_time: datetime, range=False, advanced_price=False ) -> Interval: """Generate a mock forecast interval.""" start_time = end_time - timedelta(minutes=30) - return Interval( + per_kwh = 8.8 + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 + interval = Interval( ForecastInterval( type="ForecastInterval", duration=30, spot_per_kwh=1.1, - per_kwh=8.8, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -79,12 +102,20 @@ def generate_forecast_interval( estimate=True, ) ) + if range: + interval.actual_instance.range = Range(min=6.7, max=9.1) + if advanced_price: + interval.actual_instance.advanced_price = AdvancedPrice( + low=6.7, predicted=9.0, high=10.2 + ) + return interval GENERAL_ONLY_SITE_ID = "01FG2K6V5TB6X9W0EWPPMZD6MJ" GENERAL_AND_CONTROLLED_SITE_ID = "01FG2MC8RF7GBC4KJXP3YFZ162" GENERAL_AND_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW84VP50S" GENERAL_AND_CONTROLLED_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW847S50S" +GENERAL_FOR_FAIL = "01JVCEYVSD5HGJG0KT7RNM91GG" GENERAL_CHANNEL = [ generate_current_interval( @@ -101,6 +132,21 @@ GENERAL_CHANNEL = [ ), ] +GENERAL_CHANNEL_WITH_RANGE = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:00:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:30:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T10:00:00+10:00"), range=True + ), +] + CONTROLLED_LOAD_CHANNEL = [ generate_current_interval( ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") @@ -131,3 +177,93 @@ FEED_IN_CHANNEL = [ ChannelType.FEEDIN, parser.parse("2021-09-21T10:00:00+10:00") ), ] + +GENERAL_FORECASTS = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), +] + +FORECASTS = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_current_interval( + ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_current_interval( + ChannelType.FEEDIN, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), +] diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 6faabc924b4..0e82d81f4e8 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -9,7 +9,6 @@ from unittest.mock import Mock, patch from amberelectric import ApiException from amberelectric.models.channel import Channel, ChannelType from amberelectric.models.interval import Interval -from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.models.site import Site from amberelectric.models.site_status import SiteStatus from amberelectric.models.spike_status import SpikeStatus @@ -17,10 +16,7 @@ from dateutil import parser import pytest from homeassistant.components.amberelectric.const import CONF_SITE_ID, CONF_SITE_NAME -from homeassistant.components.amberelectric.coordinator import ( - AmberUpdateCoordinator, - normalize_descriptor, -) +from homeassistant.components.amberelectric.coordinator import AmberUpdateCoordinator from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed @@ -98,18 +94,6 @@ def mock_api_current_price() -> Generator: yield instance -def test_normalize_descriptor() -> None: - """Test normalizing descriptors works correctly.""" - assert normalize_descriptor(None) is None - assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" - assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" - assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" - assert normalize_descriptor(PriceDescriptor.LOW) == "low" - assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" - assert normalize_descriptor(PriceDescriptor.HIGH) == "high" - assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" - - async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None: """Test fetching a site with only a general channel.""" @@ -120,7 +104,7 @@ async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -152,7 +136,7 @@ async def test_fetch_no_general_site( await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) @@ -166,7 +150,7 @@ async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -217,7 +201,7 @@ async def test_fetch_general_and_controlled_load_site( result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_AND_CONTROLLED_SITE_ID, next=48 + GENERAL_AND_CONTROLLED_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -257,7 +241,7 @@ async def test_fetch_general_and_feed_in_site( result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_AND_FEED_IN_SITE_ID, next=48 + GENERAL_AND_FEED_IN_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance diff --git a/tests/components/amberelectric/test_helpers.py b/tests/components/amberelectric/test_helpers.py new file mode 100644 index 00000000000..958c60fd1b3 --- /dev/null +++ b/tests/components/amberelectric/test_helpers.py @@ -0,0 +1,17 @@ +"""Test formatters.""" + +from amberelectric.models.price_descriptor import PriceDescriptor + +from homeassistant.components.amberelectric.helpers import normalize_descriptor + + +def test_normalize_descriptor() -> None: + """Test normalizing descriptors works correctly.""" + assert normalize_descriptor(None) is None + assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" + assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" + assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" + assert normalize_descriptor(PriceDescriptor.LOW) == "low" + assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" + assert normalize_descriptor(PriceDescriptor.HIGH) == "high" + assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index 203b65d6df6..0d979a2021c 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -1,119 +1,26 @@ """Test the Amber Electric Sensors.""" -from collections.abc import AsyncGenerator -from unittest.mock import Mock, patch - -from amberelectric.models.current_interval import CurrentInterval -from amberelectric.models.interval import Interval -from amberelectric.models.range import Range import pytest -from homeassistant.components.amberelectric.const import ( - CONF_SITE_ID, - CONF_SITE_NAME, - DOMAIN, -) -from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .helpers import ( - CONTROLLED_LOAD_CHANNEL, - FEED_IN_CHANNEL, - GENERAL_AND_CONTROLLED_SITE_ID, - GENERAL_AND_FEED_IN_SITE_ID, - GENERAL_CHANNEL, - GENERAL_ONLY_SITE_ID, -) - -from tests.common import MockConfigEntry - -MOCK_API_TOKEN = "psk_0000000000000000" +from . import MockConfigEntry, setup_integration -@pytest.fixture -async def setup_general(hass: HomeAssistant) -> AsyncGenerator[Mock]: - """Set up general channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_SITE_NAME: "mock_title", - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_ONLY_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_general_and_controlled_load( - hass: HomeAssistant, -) -> AsyncGenerator[Mock]: - """Set up general channel and controller load channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_AND_CONTROLLED_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock( - return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_general_and_feed_in(hass: HomeAssistant) -> AsyncGenerator[Mock]: - """Set up general channel and feed in channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_AND_FEED_IN_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock( - return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_general_price_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Test the General Price sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price") assert price - assert price.state == "0.08" + assert price.state == "0.09" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 0.08 + assert attributes["per_kwh"] == 0.09 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -126,32 +33,36 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> assert attributes.get("range_min") is None assert attributes.get("range_max") is None - with_range: list[CurrentInterval] = GENERAL_CHANNEL - with_range[0].actual_instance.range = Range(min=7.8, max=12.4) - - setup_general.get_current_price.return_value = with_range - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range") +async def test_general_price_sensor_with_range( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: + """Test the General Price sensor with a range.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price") assert price attributes = price.attributes - assert attributes.get("range_min") == 0.08 - assert attributes.get("range_max") == 0.12 + assert attributes.get("range_min") == 0.07 + assert attributes.get("range_max") == 0.09 -@pytest.mark.usefixtures("setup_general_and_controlled_load") -async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_general_and_controlled_load_price_sensor( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: """Test the Controlled Price sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_price") assert price - assert price.state == "0.08" + assert price.state == "0.04" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 0.08 + assert attributes["per_kwh"] == 0.04 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -163,17 +74,20 @@ async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> assert attributes["attribution"] == "Data provided by Amber Electric" -@pytest.mark.usefixtures("setup_general_and_feed_in") -async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_general_and_feed_in_price_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_price") assert price - assert price.state == "-0.08" + assert price.state == "-0.01" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == -0.08 + assert attributes["per_kwh"] == -0.01 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -185,10 +99,12 @@ async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: assert attributes["attribution"] == "Data provided by Amber Electric" +@pytest.mark.usefixtures("mock_amber_client_general_channel") async def test_general_forecast_sensor( - hass: HomeAssistant, setup_general: Mock + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry ) -> None: """Test the General Forecast sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_forecast") assert price @@ -212,29 +128,33 @@ async def test_general_forecast_sensor( assert first_forecast.get("range_min") is None assert first_forecast.get("range_max") is None - with_range: list[Interval] = GENERAL_CHANNEL - with_range[1].actual_instance.range = Range(min=7.8, max=12.4) - - setup_general.get_current_price.return_value = with_range - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range") +async def test_general_forecast_sensor_with_range( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: + """Test the General Forecast sensor with a range.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_forecast") assert price attributes = price.attributes first_forecast = attributes["forecasts"][0] - assert first_forecast.get("range_min") == 0.08 - assert first_forecast.get("range_max") == 0.12 + assert first_forecast.get("range_min") == 0.07 + assert first_forecast.get("range_max") == 0.09 -@pytest.mark.usefixtures("setup_general_and_controlled_load") -async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_controlled_load_forecast_sensor( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: """Test the Controlled Load Forecast sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_forecast") assert price - assert price.state == "0.09" + assert price.state == "0.04" attributes = price.attributes assert attributes["channel_type"] == "controlledLoad" assert attributes["attribution"] == "Data provided by Amber Electric" @@ -242,7 +162,7 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == 0.09 + assert first_forecast["per_kwh"] == 0.04 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" @@ -252,13 +172,16 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: assert first_forecast["descriptor"] == "very_low" -@pytest.mark.usefixtures("setup_general_and_feed_in") -async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_feed_in_forecast_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In Forecast sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_forecast") assert price - assert price.state == "-0.09" + assert price.state == "-0.01" attributes = price.attributes assert attributes["channel_type"] == "feedIn" assert attributes["attribution"] == "Data provided by Amber Electric" @@ -266,7 +189,7 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == -0.09 + assert first_forecast["per_kwh"] == -0.01 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" @@ -276,38 +199,52 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: assert first_forecast["descriptor"] == "very_low" -@pytest.mark.usefixtures("setup_general") -def test_renewable_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_renewable_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Testing the creation of the Amber renewables sensor.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 sensor = hass.states.get("sensor.mock_title_renewables") assert sensor assert sensor.state == "51" -@pytest.mark.usefixtures("setup_general") -def test_general_price_descriptor_descriptor_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_general_price_descriptor_descriptor_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Test the General Price Descriptor sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price_descriptor") assert price assert price.state == "extremely_low" -@pytest.mark.usefixtures("setup_general_and_controlled_load") -def test_general_and_controlled_load_price_descriptor_sensor( +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_general_and_controlled_load_price_descriptor_sensor( hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, ) -> None: """Test the Controlled Price Descriptor sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) + assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_price_descriptor") assert price assert price.state == "extremely_low" -@pytest.mark.usefixtures("setup_general_and_feed_in") -def test_general_and_feed_in_price_descriptor_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_general_and_feed_in_price_descriptor_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In Price Descriptor sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) + assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_price_descriptor") assert price diff --git a/tests/components/amberelectric/test_services.py b/tests/components/amberelectric/test_services.py new file mode 100644 index 00000000000..7ef895a5d88 --- /dev/null +++ b/tests/components/amberelectric/test_services.py @@ -0,0 +1,202 @@ +"""Test the Amber Service object.""" + +import re + +import pytest +import voluptuous as vol + +from homeassistant.components.amberelectric.const import DOMAIN, SERVICE_GET_FORECASTS +from homeassistant.components.amberelectric.services import ( + ATTR_CHANNEL_TYPE, + ATTR_CONFIG_ENTRY_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_integration +from .helpers import ( + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_ONLY_SITE_ID, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_general_forecasts( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, ATTR_CHANNEL_TYPE: "general"}, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == 0.09 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_controlled_load_forecasts( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_AND_CONTROLLED_SITE_ID, + ATTR_CHANNEL_TYPE: "controlled_load", + }, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == 0.04 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_feed_in_forecasts( + hass: HomeAssistant, + general_channel_and_feed_in_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_AND_FEED_IN_SITE_ID, + ATTR_CHANNEL_TYPE: "feed_in", + }, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == -0.01 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_incorrect_channel_type( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test error when the channel type is incorrect.""" + await setup_integration(hass, general_channel_config_entry) + + with pytest.raises( + vol.error.MultipleInvalid, + match=re.escape( + "value must be one of ['controlled_load', 'feed_in', 'general'] for dictionary value @ data['channel_type']" + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, + ATTR_CHANNEL_TYPE: "incorrect", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_amber_client_general_forecasts") +async def test_unavailable_channel_type( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test error when the channel type is not found.""" + await setup_integration(hass, general_channel_config_entry) + + with pytest.raises( + ServiceValidationError, match="There is no controlled_load channel at this site" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, + ATTR_CHANNEL_TYPE: "controlled_load", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_service_entry_availability( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test the services without valid entry.""" + general_channel_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(general_channel_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id, + ATTR_CHANNEL_TYPE: "general", + }, + blocking=True, + return_response=True, + ) + + with pytest.raises( + ServiceValidationError, + match='Config entry "bad-config_id" not found in registry', + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id", ATTR_CHANNEL_TYPE: "general"}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 38cb3b485d4..fdfd3f6b285 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -203,6 +203,7 @@ 'light', ]), 'multiple': False, + 'reorder': False, }), }), }), @@ -217,6 +218,7 @@ 'binary_sensor', ]), 'multiple': False, + 'reorder': False, }), }), }), diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index c67691dfa1a..52c544dc541 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -37,7 +37,7 @@ google_enabled | False cloud_ice_servers_enabled | True remote_server | us-west-1 - certificate_status | CertificateStatus.READY + certificate_status | ready instance_id | 12345678901234567890 can_reach_cert_server | Exception: Unexpected exception can_reach_cloud_auth | Failed: unreachable diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 84630bc0320..f125a5cbdae 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1050,7 +1050,7 @@ async def test_websocket_subscription_not_logged_in( client = await hass_ws_client(hass) with patch( - "hass_nabucasa.cloud_api.async_subscription_info", + "hass_nabucasa.payments_api.PaymentsApi.subscription_info", return_value={"return": "value"}, ): await client.send_json({"id": 5, "type": "cloud/subscription"}) diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 6575ab2ac98..8dfe879ee2b 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -1,13 +1,14 @@ """Conversation test helpers.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch import pytest from homeassistant.components import conversation from homeassistant.components.shopping_list import intent as sl_intent from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from . import MockAgent @@ -15,6 +16,14 @@ from . import MockAgent from tests.common import MockConfigEntry +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + @pytest.fixture def mock_agent_support_all(hass: HomeAssistant) -> MockAgent: """Mock agent that supports all languages.""" @@ -25,6 +34,19 @@ def mock_agent_support_all(hass: HomeAssistant) -> MockAgent: return agent +@pytest.fixture +def mock_conversation_input(hass: HomeAssistant) -> conversation.ConversationInput: + """Return a conversation input instance.""" + return conversation.ConversationInput( + text="Hello", + context=Context(), + conversation_id=None, + agent_id="mock-agent-id", + device_id=None, + language="en", + ) + + @pytest.fixture(autouse=True) def mock_shopping_list_io(): """Stub out the persistence.""" diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 0e2a384f1da..811c045dd70 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -1,6 +1,5 @@ """Test the conversation session.""" -from collections.abc import Generator from dataclasses import asdict from datetime import timedelta from unittest.mock import AsyncMock, Mock, patch @@ -26,27 +25,6 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -@pytest.fixture -def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: - """Return a conversation input instance.""" - return ConversationInput( - text="Hello", - context=Context(), - conversation_id=None, - agent_id="mock-agent-id", - device_id=None, - language="en", - ) - - -@pytest.fixture -def mock_ulid() -> Generator[Mock]: - """Mock the ulid library.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" - yield mock_ulid_now - - async def test_cleanup( hass: HomeAssistant, mock_conversation_input: ConversationInput, diff --git a/tests/components/conversation/test_util.py b/tests/components/conversation/test_util.py new file mode 100644 index 00000000000..196de4ad2fb --- /dev/null +++ b/tests/components/conversation/test_util.py @@ -0,0 +1,39 @@ +"""Tests for conversation utility functions.""" + +from homeassistant.components import conversation +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session, intent, llm + + +async def test_async_get_result_from_chat_log( + hass: HomeAssistant, + mock_conversation_input: conversation.ConversationInput, +) -> None: + """Test getting result from chat log.""" + intent_response = intent.IntentResponse(language="en") + with ( + chat_session.async_get_chat_session(hass) as session, + conversation.async_get_chat_log( + hass, session, mock_conversation_input + ) as chat_log, + ): + chat_log.content.extend( + [ + conversation.ToolResultContent( + agent_id="mock-agent-id", + tool_call_id="mock-tool-call-id", + tool_name="mock-tool-name", + tool_result=llm.IntentResponseDict(intent_response), + ), + conversation.AssistantContent( + agent_id="mock-agent-id", + content="This is a response.", + ), + ] + ) + result = conversation.async_get_result_from_chat_log( + mock_conversation_input, chat_log + ) + # Original intent response is returned with speech set + assert result.response is intent_response + assert result.response.speech["plain"]["speech"] == "This is a response." diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index ef2caf2eab1..c5595d7fcbe 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -73,12 +73,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index 3a627efd3f1..a497bd964ec 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -37,11 +37,15 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service from tests.components.vacuum import common -ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".lower() -ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".lower() -ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".lower() -ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".lower() -ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".lower() +ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".replace(" ", "_").lower() +ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".replace( + " ", "_" +).lower() +ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".replace( + " ", "_" +).lower() +ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".replace(" ", "_").lower() +ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".replace(" ", "_").lower() @pytest.fixture diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 10092e30ca0..ee458ea54cd 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -27,8 +27,25 @@ from tests.common import ( mock_restore_cache_with_extra_data, ) +A1 = {"attr": "value1"} +A2 = {"attr": "value2"} -async def test_state(hass: HomeAssistant) -> None: + +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2], + ], +) +async def test_state( + hass: HomeAssistant, + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test derivative sensor state.""" config = { "sensor": { @@ -45,12 +62,13 @@ async def test_state(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() with freeze_time(base) as freezer: - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + for extra_attributes in attributes: + hass.states.async_set( + entity_id, 1, extra_attributes, force_update=force_update + ) + await hass.async_block_till_done() - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) state = hass.states.get("sensor.derivative") assert state is not None @@ -61,7 +79,24 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("unit_of_measurement") == "kW" -async def test_no_change(hass: HomeAssistant) -> None: +# Test unchanged states work both with and without max_sub_interval +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1, A1, A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2, A1, A2], + ], +) +async def test_no_change( + hass: HomeAssistant, + extra_config: dict[str, Any], + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test derivative sensor state updated when source sensor doesn't change.""" config = { "sensor": { @@ -71,6 +106,7 @@ async def test_no_change(hass: HomeAssistant) -> None: "unit": "kW", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) @@ -78,20 +114,13 @@ async def test_no_change(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() with freeze_time(base) as freezer: - hass.states.async_set(entity_id, 0, {}) - await hass.async_block_till_done() + for value, extra_attributes in zip([0, 1, 1, 1], attributes, strict=True): + hass.states.async_set( + entity_id, value, extra_attributes, force_update=force_update + ) + await hass.async_block_till_done() - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() - - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() - - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) state = hass.states.get("sensor.derivative") assert state is not None @@ -138,7 +167,7 @@ async def setup_tests( # Testing a energy sensor with non-monotonic intervals and values base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() @@ -213,7 +242,24 @@ async def test_dataSet6(hass: HomeAssistant) -> None: await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) -async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: +# Test unchanged states work both with and without max_sub_interval +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1] * 10 + [A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2] * 10 + [A1], + ], +) +async def test_data_moving_average_with_zeroes( + hass: HomeAssistant, + extra_config: dict[str, Any], + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test that zeroes are properly handled within the time window.""" # We simulate the following situation: # The temperature rises 1 °C per minute for 10 minutes long. Then, it @@ -235,16 +281,21 @@ async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: "time_window": {"seconds": time_window}, "unit_time": UnitOfTime.MINUTES, "round": 1, - }, + } + | extra_config, ) base = dt_util.utcnow() with freeze_time(base) as freezer: last_derivative = 0 - for time, value in zip(times, temperature_values, strict=True): + for time, value, extra_attributes in zip( + times, temperature_values, attributes, strict=True + ): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}) + hass.states.async_set( + entity_id, value, extra_attributes, force_update=force_update + ) await hass.async_block_till_done() state = hass.states.get("sensor.power") @@ -273,7 +324,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N for temperature in range(30): temperature_values += [temperature] * 2 # two values per minute time_window = 600 - times = list(range(0, 1800 + 30, 30)) + times = list(range(0, 1800, 30)) config, entity_id = await _setup_sensor( hass, @@ -286,7 +337,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -330,7 +381,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -368,7 +419,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: base = dt_util.utcnow() previous = 0 with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -506,7 +557,7 @@ async def test_sub_intervals_with_time_window(hass: HomeAssistant) -> None: base = dt_util.utcnow() with freeze_time(base) as freezer: last_state_change = None - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}, force_update=True) @@ -636,7 +687,7 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: actual_times = [] actual_values = [] with freeze_time(base_time) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): current_time = base_time + timedelta(seconds=time) freezer.move_to(current_time) hass.states.async_set( @@ -724,7 +775,7 @@ async def test_unavailable( # Testing a energy sensor with non-monotonic intervals and values base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value, expect in zip(times, values, expected_state, strict=False): + for time, value, expect in zip(times, values, expected_state, strict=True): freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() @@ -759,7 +810,7 @@ async def test_unavailable_2( base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index e06b88432a9..ff16731b44e 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -40,7 +40,6 @@ async def test_generic_alarm_control_panel_requires_code( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME @@ -173,7 +172,6 @@ async def test_generic_alarm_control_panel_no_code( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME @@ -219,7 +217,6 @@ async def test_generic_alarm_control_panel_missing_state( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index bfcc35b2e6a..2fdf53dc5ea 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -953,7 +953,6 @@ async def test_tts_format_from_media_player( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1020,7 +1019,6 @@ async def test_tts_minimal_format_from_media_player( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1156,7 +1154,6 @@ async def test_announce_media_id( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1437,7 +1434,6 @@ async def test_start_conversation_media_id( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index d6e94e61766..0e3bcc5a115 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -24,7 +24,6 @@ async def test_binary_sensor_generic_entity( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] esphome_state, hass_state = binary_state @@ -52,7 +51,6 @@ async def test_status_binary_sensor( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", is_status_binary_sensor=True, ) ] @@ -80,7 +78,6 @@ async def test_binary_sensor_missing_state( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [BinarySensorState(key=1, state=True, missing_state=True)] @@ -107,7 +104,6 @@ async def test_binary_sensor_has_state_false( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [] @@ -152,14 +148,12 @@ async def test_binary_sensors_same_key_different_device_id( object_id="sensor", key=1, name="Motion", - unique_id="motion_1", device_id=11111111, ), BinarySensorInfo( object_id="sensor", key=1, name="Motion", - unique_id="motion_2", device_id=22222222, ), ] @@ -235,14 +229,12 @@ async def test_binary_sensor_main_and_sub_device_same_key( object_id="main_sensor", key=1, name="Main Sensor", - unique_id="main_1", device_id=0, # Main device ), BinarySensorInfo( object_id="sub_sensor", key=1, name="Sub Sensor", - unique_id="sub_1", device_id=11111111, ), ] diff --git a/tests/components/esphome/test_button.py b/tests/components/esphome/test_button.py index 3cedc3526d4..b85dd04e6b7 100644 --- a/tests/components/esphome/test_button.py +++ b/tests/components/esphome/test_button.py @@ -18,7 +18,6 @@ async def test_button_generic_entity( object_id="mybutton", key=1, name="my button", - unique_id="my_button", ) ] states = [] diff --git a/tests/components/esphome/test_camera.py b/tests/components/esphome/test_camera.py index e29eed16d9f..2f3966fe1f6 100644 --- a/tests/components/esphome/test_camera.py +++ b/tests/components/esphome/test_camera.py @@ -30,7 +30,6 @@ async def test_camera_single_image( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -75,7 +74,6 @@ async def test_camera_single_image_unavailable_before_requested( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -113,7 +111,6 @@ async def test_camera_single_image_unavailable_during_request( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -155,7 +152,6 @@ async def test_camera_stream( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -212,7 +208,6 @@ async def test_camera_stream_unavailable( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -249,7 +244,6 @@ async def test_camera_stream_with_disconnection( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 5c907eef3b1..c574764e3c9 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -58,7 +58,6 @@ async def test_climate_entity( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_action=True, visual_min_temperature=10.0, @@ -110,7 +109,6 @@ async def test_climate_entity_with_step_and_two_point( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, visual_target_temperature_step=2, @@ -187,7 +185,6 @@ async def test_climate_entity_with_step_and_target_temp( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, visual_target_temperature_step=2, visual_current_temperature_step=2, @@ -345,7 +342,6 @@ async def test_climate_entity_with_humidity( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, supports_action=True, @@ -409,7 +405,6 @@ async def test_climate_entity_with_inf_value( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, supports_action=True, @@ -465,7 +460,6 @@ async def test_climate_entity_attributes( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, visual_target_temperature_step=2, visual_current_temperature_step=2, @@ -520,7 +514,6 @@ async def test_climate_entity_attribute_current_temperature_unsupported( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=False, ) ] diff --git a/tests/components/esphome/test_cover.py b/tests/components/esphome/test_cover.py index 93524905f6b..d7b92e490fe 100644 --- a/tests/components/esphome/test_cover.py +++ b/tests/components/esphome/test_cover.py @@ -41,7 +41,6 @@ async def test_cover_entity( object_id="mycover", key=1, name="my cover", - unique_id="my_cover", supports_position=True, supports_tilt=True, supports_stop=True, @@ -169,7 +168,6 @@ async def test_cover_entity_without_position( object_id="mycover", key=1, name="my cover", - unique_id="my_cover", supports_position=False, supports_tilt=False, supports_stop=False, diff --git a/tests/components/esphome/test_date.py b/tests/components/esphome/test_date.py index 387838e0b23..9e555eb98c2 100644 --- a/tests/components/esphome/test_date.py +++ b/tests/components/esphome/test_date.py @@ -26,7 +26,6 @@ async def test_generic_date_entity( object_id="mydate", key=1, name="my date", - unique_id="my_date", ) ] states = [DateState(key=1, year=2024, month=12, day=31)] @@ -62,7 +61,6 @@ async def test_generic_date_missing_state( object_id="mydate", key=1, name="my date", - unique_id="my_date", ) ] states = [DateState(key=1, missing_state=True)] diff --git a/tests/components/esphome/test_datetime.py b/tests/components/esphome/test_datetime.py index 6fcfe7ed947..940fae5cfef 100644 --- a/tests/components/esphome/test_datetime.py +++ b/tests/components/esphome/test_datetime.py @@ -26,7 +26,6 @@ async def test_generic_datetime_entity( object_id="mydatetime", key=1, name="my datetime", - unique_id="my_datetime", ) ] states = [DateTimeState(key=1, epoch_seconds=1713270896)] @@ -65,7 +64,6 @@ async def test_generic_datetime_missing_state( object_id="mydatetime", key=1, name="my datetime", - unique_id="my_datetime", ) ] states = [DateTimeState(key=1, missing_state=True)] diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index f364e1f528f..9b3c08bb77d 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -51,13 +51,11 @@ async def test_entities_removed( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), BinarySensorInfo( object_id="mybinary_sensor_to_be_removed", key=2, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -100,7 +98,6 @@ async def test_entities_removed( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] states = [ @@ -140,13 +137,11 @@ async def test_entities_removed_after_reload( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), BinarySensorInfo( object_id="mybinary_sensor_to_be_removed", key=2, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -214,7 +209,6 @@ async def test_entities_removed_after_reload( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] mock_device.client.list_entities_services = AsyncMock( @@ -267,7 +261,6 @@ async def test_entities_for_entire_platform_removed( object_id="mybinary_sensor_to_be_removed", key=1, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -325,7 +318,6 @@ async def test_entity_info_object_ids( object_id="object_id_is_used", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [] @@ -350,13 +342,11 @@ async def test_deep_sleep_device( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), SensorInfo( object_id="my_sensor", key=3, name="my sensor", - unique_id="my_sensor", ), ] states = [ @@ -456,7 +446,6 @@ async def test_esphome_device_without_friendly_name( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] states = [ @@ -486,7 +475,6 @@ async def test_entity_without_name_device_with_friendly_name( object_id="mybinary_sensor", key=1, name="", - unique_id="my_binary_sensor", ), ] states = [ @@ -519,7 +507,6 @@ async def test_entity_id_preserved_on_upgrade( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -560,7 +547,6 @@ async def test_entity_id_preserved_on_upgrade_old_format_entity_id( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -601,7 +587,6 @@ async def test_entity_id_preserved_on_upgrade_when_in_storage( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -660,7 +645,6 @@ async def test_deep_sleep_added_after_setup( object_id="test", key=1, name="test", - unique_id="test", ), ], states=[ @@ -732,7 +716,6 @@ async def test_entity_assignment_to_sub_device( object_id="main_sensor", key=1, name="Main Sensor", - unique_id="main_sensor", device_id=0, ), # Entity for sub device 1 @@ -740,7 +723,6 @@ async def test_entity_assignment_to_sub_device( object_id="motion", key=2, name="Motion", - unique_id="motion", device_id=11111111, ), # Entity for sub device 2 @@ -748,7 +730,6 @@ async def test_entity_assignment_to_sub_device( object_id="door", key=3, name="Door", - unique_id="door", device_id=22222222, ), ] @@ -932,7 +913,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", # device_id omitted - entity belongs to main device ), ] @@ -964,7 +944,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", device_id=11111111, # Now on sub device 1 ), ] @@ -993,7 +972,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", device_id=22222222, # Now on sub device 2 ), ] @@ -1020,7 +998,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", # device_id omitted - back to main device ), ] @@ -1063,7 +1040,6 @@ async def test_entity_id_uses_sub_device_name( object_id="main_sensor", key=1, name="Main Sensor", - unique_id="main_sensor", device_id=0, ), # Entity for sub device 1 @@ -1071,7 +1047,6 @@ async def test_entity_id_uses_sub_device_name( object_id="motion", key=2, name="Motion", - unique_id="motion", device_id=11111111, ), # Entity for sub device 2 @@ -1079,7 +1054,6 @@ async def test_entity_id_uses_sub_device_name( object_id="door", key=3, name="Door", - unique_id="door", device_id=22222222, ), # Entity without name on sub device @@ -1087,7 +1061,6 @@ async def test_entity_id_uses_sub_device_name( object_id="sensor_no_name", key=4, name="", - unique_id="sensor_no_name", device_id=11111111, ), ] @@ -1147,7 +1120,6 @@ async def test_entity_id_with_empty_sub_device_name( object_id="sensor", key=1, name="Sensor", - unique_id="sensor", device_id=11111111, ), ] @@ -1187,8 +1159,7 @@ async def test_unique_id_migration_when_entity_moves_between_devices( BinarySensorInfo( object_id="temperature", key=1, - name="Temperature", - unique_id="unused", # This field is not used by the integration + name="Temperature", # This field is not used by the integration device_id=0, # Main device ), ] @@ -1250,8 +1221,7 @@ async def test_unique_id_migration_when_entity_moves_between_devices( BinarySensorInfo( object_id="temperature", # Same object_id key=1, # Same key - this is what identifies the entity - name="Temperature", - unique_id="unused", # This field is not used + name="Temperature", # This field is not used device_id=22222222, # Now on sub-device ), ] @@ -1312,7 +1282,6 @@ async def test_unique_id_migration_sub_device_to_main_device( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=22222222, # On sub-device ), ] @@ -1347,7 +1316,6 @@ async def test_unique_id_migration_sub_device_to_main_device( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=0, # Now on main device ), ] @@ -1407,7 +1375,6 @@ async def test_unique_id_migration_between_sub_devices( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=22222222, # On kitchen_controller ), ] @@ -1442,7 +1409,6 @@ async def test_unique_id_migration_between_sub_devices( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=33333333, # Now on bedroom_controller ), ] @@ -1501,7 +1467,6 @@ async def test_entity_device_id_rename_in_yaml( object_id="sensor", key=1, name="Sensor", - unique_id="unused", device_id=11111111, ), ] @@ -1563,7 +1528,6 @@ async def test_entity_device_id_rename_in_yaml( object_id="sensor", # Same object_id key=1, # Same key name="Sensor", - unique_id="unused", device_id=99999999, # New device_id after rename ), ] @@ -1636,8 +1600,7 @@ async def test_entity_with_unicode_name( BinarySensorInfo( object_id=sanitized_object_id, # ESPHome sends the sanitized version key=1, - name=unicode_name, # But also sends the original Unicode name - unique_id="unicode_sensor", + name=unicode_name, # But also sends the original Unicode name, ) ] states = [BinarySensorState(key=1, state=True)] @@ -1677,8 +1640,7 @@ async def test_entity_without_name_uses_device_name_only( BinarySensorInfo( object_id="some_sanitized_id", key=1, - name="", # Empty name - unique_id="no_name_sensor", + name="", # Empty name, ) ] states = [BinarySensorState(key=1, state=True)] diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 886e5317462..044c3c7a8f1 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -15,49 +15,6 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockGenericDeviceEntryType -async def test_migrate_entity_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_client: APIClient, - mock_generic_device_entry: MockGenericDeviceEntryType, -) -> None: - """Test a generic sensor entity unique id migration.""" - entity_registry.async_get_or_create( - "sensor", - "esphome", - "my_sensor", - suggested_object_id="old_sensor", - disabled_by=None, - ) - entity_info = [ - SensorInfo( - object_id="mysensor", - key=1, - name="my sensor", - unique_id="my_sensor", - entity_category=ESPHomeEntityCategory.DIAGNOSTIC, - icon="mdi:leaf", - ) - ] - states = [SensorState(key=1, state=50)] - user_service = [] - await mock_generic_device_entry( - mock_client=mock_client, - entity_info=entity_info, - user_service=user_service, - states=states, - ) - state = hass.states.get("sensor.old_sensor") - assert state is not None - assert state.state == "50" - entry = entity_registry.async_get("sensor.old_sensor") - assert entry is not None - assert entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") is None - # Note that ESPHome includes the EntityInfo type in the unique id - # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" - - async def test_migrate_entity_unique_id_downgrade_upgrade( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -84,7 +41,6 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", entity_category=ESPHomeEntityCategory.DIAGNOSTIC, icon="mdi:leaf", ) diff --git a/tests/components/esphome/test_event.py b/tests/components/esphome/test_event.py index 2756aa6d251..3cff3184bf1 100644 --- a/tests/components/esphome/test_event.py +++ b/tests/components/esphome/test_event.py @@ -20,7 +20,6 @@ async def test_generic_event_entity( object_id="myevent", key=1, name="my event", - unique_id="my_event", event_types=["type1", "type2"], device_class=EventDeviceClass.BUTTON, ) diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index a33be1a6fca..763e95d3e6f 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -44,7 +44,6 @@ async def test_fan_entity_with_all_features_old_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supports_direction=True, supports_speed=True, supports_oscillation=True, @@ -147,7 +146,6 @@ async def test_fan_entity_with_all_features_new_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supported_speed_count=4, supports_direction=True, supports_speed=True, @@ -317,7 +315,6 @@ async def test_fan_entity_with_no_features_new_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supports_direction=False, supports_speed=False, supports_oscillation=False, diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 4377a714b17..bf602a6fa84 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -56,7 +56,6 @@ async def test_light_on_off( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ESPColorMode.ON_OFF], @@ -98,7 +97,6 @@ async def test_light_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[LightColorCapability.BRIGHTNESS], @@ -226,7 +224,6 @@ async def test_light_legacy_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[LightColorCapability.BRIGHTNESS, 2], @@ -282,7 +279,6 @@ async def test_light_brightness_on_off( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ESPColorMode.ON_OFF, ESPColorMode.BRIGHTNESS], @@ -358,7 +354,6 @@ async def test_light_legacy_white_converted_to_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -423,7 +418,6 @@ async def test_light_legacy_white_with_rgb( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_mode, color_mode_2], @@ -478,7 +472,6 @@ async def test_light_brightness_on_off_with_unknown_color_mode( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -555,7 +548,6 @@ async def test_light_on_and_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -607,7 +599,6 @@ async def test_rgb_color_temp_light( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=color_modes, @@ -698,7 +689,6 @@ async def test_light_rgb( object_id="mylight", key=1, name="my light", - unique_id="my_light", supported_color_modes=[ LightColorCapability.RGB | LightColorCapability.ON_OFF @@ -821,7 +811,6 @@ async def test_light_rgbw( object_id="mylight", key=1, name="my light", - unique_id="my_light", supported_color_modes=[ LightColorCapability.RGB | LightColorCapability.WHITE @@ -991,7 +980,6 @@ async def test_light_rgbww_with_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -1200,7 +1188,6 @@ async def test_light_rgbww_without_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -1439,7 +1426,6 @@ async def test_light_color_temp( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1514,7 +1500,6 @@ async def test_light_color_temp_no_mireds_set( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=0, max_mireds=0, supported_color_modes=[ @@ -1610,7 +1595,6 @@ async def test_light_color_temp_legacy( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1695,7 +1679,6 @@ async def test_light_rgb_legacy( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1795,7 +1778,6 @@ async def test_light_effects( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, effects=["effect1", "effect2"], @@ -1859,7 +1841,6 @@ async def test_only_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_modes], @@ -1955,7 +1936,6 @@ async def test_light_no_color_modes( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_mode], diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index eaa03947a7d..93e9c0704c3 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -34,7 +34,6 @@ async def test_lock_entity_no_open( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", supports_open=False, requires_code=False, ) @@ -72,7 +71,6 @@ async def test_lock_entity_start_locked( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", ) ] states = [LockEntityState(key=1, state=ESPHomeLockState.LOCKED)] @@ -99,7 +97,6 @@ async def test_lock_entity_supports_open( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", supports_open=True, requires_code=True, ) diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 6d7a3b220d1..232f7e1f06e 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -55,7 +55,6 @@ async def test_media_player_entity( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, ) ] @@ -202,7 +201,6 @@ async def test_media_player_entity_with_source( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, ) ] @@ -318,7 +316,6 @@ async def test_media_player_proxy( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -477,7 +474,6 @@ async def test_media_player_formats_reload_preserves_data( object_id="test_media_player", key=1, name="Test Media Player", - unique_id="test_unique_id", supports_pause=True, supported_formats=supported_formats, ) diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index d7a59222d47..02b58649fec 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -35,7 +35,6 @@ async def test_generic_number_entity( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -75,7 +74,6 @@ async def test_generic_number_nan( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -107,7 +105,6 @@ async def test_generic_number_with_unit_of_measurement_as_empty_string( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -140,7 +137,6 @@ async def test_generic_number_entity_set_when_disconnected( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index fed76ac580a..f5142367432 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -133,7 +133,6 @@ async def test_device_conflict_migration( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", is_status_binary_sensor=True, ) ] diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 6b7415889d8..14673f5ffb9 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -67,7 +67,6 @@ async def test_select_generic_entity( object_id="myselect", key=1, name="my select", - unique_id="my_select", options=["a", "b"], ) ] diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index e520b6ca259..6d3d59b9b4a 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -54,7 +54,6 @@ async def test_generic_numeric_sensor( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=50)] @@ -110,7 +109,6 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", entity_category=ESPHomeEntityCategory.DIAGNOSTIC, icon="mdi:leaf", ) @@ -147,7 +145,6 @@ async def test_generic_numeric_sensor_state_class_measurement( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", state_class=ESPHomeSensorStateClass.MEASUREMENT, device_class="power", unit_of_measurement="W", @@ -184,7 +181,6 @@ async def test_generic_numeric_sensor_device_class_timestamp( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class="timestamp", ) ] @@ -212,7 +208,6 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", legacy_last_reset_type=LastResetType.AUTO, state_class=ESPHomeSensorStateClass.MEASUREMENT, ) @@ -242,7 +237,6 @@ async def test_generic_numeric_sensor_no_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [] @@ -269,7 +263,6 @@ async def test_generic_numeric_sensor_nan_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=math.nan, missing_state=False)] @@ -296,7 +289,6 @@ async def test_generic_numeric_sensor_missing_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=True, missing_state=True)] @@ -323,7 +315,6 @@ async def test_generic_text_sensor( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [TextSensorState(key=1, state="i am a teapot")] @@ -350,7 +341,6 @@ async def test_generic_text_sensor_missing_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [TextSensorState(key=1, state=True, missing_state=True)] @@ -377,7 +367,6 @@ async def test_generic_text_sensor_device_class_timestamp( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class=SensorDeviceClass.TIMESTAMP, ) ] @@ -406,7 +395,6 @@ async def test_generic_text_sensor_device_class_date( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class=SensorDeviceClass.DATE, ) ] @@ -435,7 +423,6 @@ async def test_generic_numeric_sensor_empty_string_uom( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", unit_of_measurement="", ) ] @@ -493,7 +480,6 @@ async def test_suggested_display_precision_by_device_class( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", accuracy_decimals=expected_precision, device_class=device_class.value, unit_of_measurement=unit_of_measurement, diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py index c62101125bd..2d054a7317d 100644 --- a/tests/components/esphome/test_switch.py +++ b/tests/components/esphome/test_switch.py @@ -26,7 +26,6 @@ async def test_switch_generic_entity( object_id="myswitch", key=1, name="my switch", - unique_id="my_switch", ) ] states = [SwitchState(key=1, state=True)] @@ -78,14 +77,12 @@ async def test_switch_sub_device_non_zero_device_id( object_id="main_switch", key=1, name="Main Switch", - unique_id="main_switch_1", device_id=0, # Main device ), SwitchInfo( object_id="sub_switch", key=2, name="Sub Switch", - unique_id="sub_switch_1", device_id=11111111, # Sub-device ), ] diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py index f8c1d33e224..b1e84544e3e 100644 --- a/tests/components/esphome/test_text.py +++ b/tests/components/esphome/test_text.py @@ -26,7 +26,6 @@ async def test_generic_text_entity( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, @@ -66,7 +65,6 @@ async def test_generic_text_entity_no_state( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, @@ -97,7 +95,6 @@ async def test_generic_text_entity_missing_state( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, diff --git a/tests/components/esphome/test_time.py b/tests/components/esphome/test_time.py index 75e2a0dc664..176510d4e65 100644 --- a/tests/components/esphome/test_time.py +++ b/tests/components/esphome/test_time.py @@ -26,7 +26,6 @@ async def test_generic_time_entity( object_id="mytime", key=1, name="my time", - unique_id="my_time", ) ] states = [TimeState(key=1, hour=12, minute=34, second=56)] @@ -62,7 +61,6 @@ async def test_generic_time_missing_state( object_id="mytime", key=1, name="my time", - unique_id="my_time", ) ] states = [TimeState(key=1, missing_state=True)] diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 96b77281485..859189f5ed9 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -436,7 +436,6 @@ async def test_generic_device_update_entity( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] states = [ @@ -470,7 +469,6 @@ async def test_generic_device_update_entity_has_update( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] states = [ @@ -561,7 +559,6 @@ async def test_update_entity_release_notes( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py index aaa52551115..4f57a27708c 100644 --- a/tests/components/esphome/test_valve.py +++ b/tests/components/esphome/test_valve.py @@ -36,7 +36,6 @@ async def test_valve_entity( object_id="myvalve", key=1, name="my valve", - unique_id="my_valve", supports_position=True, supports_stop=True, ) @@ -134,7 +133,6 @@ async def test_valve_entity_without_position( object_id="myvalve", key=1, name="my valve", - unique_id="my_valve", supports_position=False, supports_stop=False, ) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index f28742cdd0a..a6c35513dc3 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -26,6 +26,7 @@ from homeassistant.components.frontend import ( ) from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component @@ -408,6 +409,35 @@ async def test_themes_reload_themes( assert msg["result"]["default_theme"] == "default" +@pytest.mark.usefixtures("frontend") +async def test_themes_reload_invalid( + hass: HomeAssistant, themes_ws_client: MockHAClientWebSocket +) -> None: + """Test frontend.reload_themes service with an invalid theme.""" + + with patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "pink"}}}}, + ): + await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + + with ( + patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={DOMAIN: {CONF_THEMES: {"sad": "blue"}}}, + ), + pytest.raises(HomeAssistantError, match="Failed to reload themes"), + ): + await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) + + msg = await themes_ws_client.receive_json() + + assert msg["result"]["themes"] == {"happy": {"primary-color": "pink"}} + assert msg["result"]["default_theme"] == "default" + + async def test_missing_themes(ws_client: MockHAClientWebSocket) -> None: """Test that themes API works when themes are not defined.""" await ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index d363e0e69f3..0f877fce7db 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -29,8 +29,18 @@ def mock_entry(): ) -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: +@pytest.fixture(scope="module") +def mock_unload_entry() -> Generator[AsyncMock]: + """Override async_unload_entry.""" + with patch( + "homeassistant.components.gardena_bluetooth.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture(scope="module") +def mock_setup_entry(mock_unload_entry) -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.gardena_bluetooth.async_setup_entry", diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr index c89ead450d2..4bc1e7e8dcb 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_number.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -2,6 +2,7 @@ # name: test_bluetooth_error_unavailable StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -20,6 +21,7 @@ # name: test_bluetooth_error_unavailable.1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -38,6 +40,7 @@ # name: test_bluetooth_error_unavailable.2 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -56,6 +59,7 @@ # name: test_bluetooth_error_unavailable.3 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -110,6 +114,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -128,6 +133,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -146,6 +152,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].2 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -164,6 +171,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].3 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -182,6 +190,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw2-number.mock_title_open_for] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Open for', 'max': 1440, 'min': 0.0, @@ -200,6 +209,7 @@ # name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -218,6 +228,7 @@ # name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, diff --git a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr index c030332e75b..4a0da40a143 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr @@ -2,6 +2,7 @@ # name: test_setup StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'water', 'friendly_name': 'Mock Title', 'supported_features': , }), @@ -16,6 +17,7 @@ # name: test_setup.1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'water', 'friendly_name': 'Mock Title', 'supported_features': , }), diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 2abdf724f61..0a071f45ef7 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -120,7 +120,6 @@ async def _test_setup_and_signaling( [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -139,7 +138,6 @@ async def _test_setup_and_signaling( [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -670,3 +668,31 @@ async def test_async_get_image( HomeAssistantError, match="Stream source is not supported by go2rtc" ): await async_get_image(hass, camera.entity_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_generic_workaround( + hass: HomeAssistant, + init_test_integration: MockCamera, + rest_client: AsyncMock, +) -> None: + """Test workaround for generic integration cameras.""" + camera = init_test_integration + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) + + rest_client.get_jpeg_snapshot.return_value = image_bytes + camera.set_stream_source("https://my_stream_url.m3u8") + + with patch.object(camera.platform, "platform_name", "generic"): + image = await async_get_image(hass, camera.entity_id) + assert image.content == image_bytes + + rest_client.streams.add.assert_called_once_with( + camera.entity_id, + [ + "ffmpeg:https://my_stream_url.m3u8", + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], + ) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index da5976f46c4..b19482957b2 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -9,6 +9,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, DEFAULT_TTS_NAME, ) from homeassistant.config_entries import ConfigEntry @@ -39,6 +40,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "subentry_id": "ulid-conversation", "unique_id": None, }, + { + "data": {}, + "subentry_type": "stt", + "title": DEFAULT_STT_NAME, + "subentry_id": "ulid-stt", + "unique_id": None, + }, { "data": {}, "subentry_type": "tts", diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index d3e27eb99d2..bceb12a9256 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -34,6 +34,14 @@ 'title': 'Google AI Conversation', 'unique_id': None, }), + 'ulid-stt': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-stt', + 'subentry_type': 'stt', + 'title': 'Google AI STT', + 'unique_id': None, + }), 'ulid-tts': dict({ 'data': dict({ }), diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index a0d34f49d37..0c57935589b 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -32,6 +32,37 @@ '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': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-stt', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI STT', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index bf3e2aedb45..52def1d06bb 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Google Generative AI Conversation config flow.""" +from typing import Any from unittest.mock import Mock, patch import pytest @@ -21,6 +22,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_AI_TASK_OPTIONS, @@ -28,8 +30,11 @@ from homeassistant.components.google_generative_ai_conversation.const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_STT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_MODEL, RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) @@ -64,11 +69,17 @@ def get_models_pager(): ) model_15_pro.name = "models/gemini-1.5-pro-latest" + model_25_flash_tts = Mock( + supported_actions=["generateContent"], + ) + model_25_flash_tts.name = "models/gemini-2.5-flash-preview-tts" + async def models_pager(): yield model_25_flash yield model_20_flash yield model_15_flash yield model_15_pro + yield model_25_flash_tts return models_pager() @@ -129,6 +140,12 @@ async def test_form(hass: HomeAssistant) -> None: "title": DEFAULT_AI_TASK_NAME, "unique_id": None, }, + { + "subentry_type": "stt", + "data": RECOMMENDED_STT_OPTIONS, + "title": DEFAULT_STT_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -157,22 +174,35 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_creating_conversation_subentry( +@pytest.mark.parametrize( + ("subentry_type", "options"), + [ + ("conversation", RECOMMENDED_CONVERSATION_OPTIONS), + ("stt", RECOMMENDED_STT_OPTIONS), + ("tts", RECOMMENDED_TTS_OPTIONS), + ("ai_task_data", RECOMMENDED_AI_TASK_OPTIONS), + ], +) +async def test_creating_subentry( hass: HomeAssistant, mock_init_component: None, mock_config_entry: MockConfigEntry, + subentry_type: str, + options: dict[str, Any], ) -> None: - """Test creating a conversation subentry.""" + """Test creating a subentry.""" + old_subentries = set(mock_config_entry.subentries) + with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): result = await hass.config_entries.subentries.async_init( - (mock_config_entry.entry_id, "conversation"), + (mock_config_entry.entry_id, subentry_type), context={"source": config_entries.SOURCE_USER}, ) - assert result["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.FORM, result assert result["step_id"] == "set_options" assert not result["errors"] @@ -182,31 +212,117 @@ async def test_creating_conversation_subentry( ): result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_NAME: "Mock name", **RECOMMENDED_CONVERSATION_OPTIONS}, + result["data_schema"]({CONF_NAME: "Mock name", **options}), ) await hass.async_block_till_done() + expected_options = options.copy() + if CONF_PROMPT in expected_options: + expected_options[CONF_PROMPT] = expected_options[CONF_PROMPT].strip() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Mock name" + assert result2["data"] == expected_options - processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() - processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + assert len(mock_config_entry.subentries) == len(old_subentries) + 1 - assert result2["data"] == processed_options + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + + assert new_subentry.subentry_type == subentry_type + assert new_subentry.data == expected_options + assert new_subentry.title == "Mock name" -async def test_creating_tts_subentry( +@pytest.mark.parametrize( + ("subentry_type", "recommended_model", "options"), + [ + ( + "conversation", + RECOMMENDED_CHAT_MODEL, + { + CONF_PROMPT: "You are Mario", + CONF_LLM_HASS_API: ["assist"], + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + }, + ), + ( + "stt", + RECOMMENDED_STT_MODEL, + { + CONF_PROMPT: "Transcribe this", + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_STT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ( + "tts", + RECOMMENDED_TTS_MODEL, + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_TTS_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ( + "ai_task_data", + RECOMMENDED_CHAT_MODEL, + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ], +) +async def test_creating_subentry_custom_options( hass: HomeAssistant, mock_init_component: None, mock_config_entry: MockConfigEntry, + subentry_type: str, + recommended_model: str, + options: dict[str, Any], ) -> None: - """Test creating a TTS subentry.""" + """Test creating a subentry with custom options.""" + old_subentries = set(mock_config_entry.subentries) + with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): result = await hass.config_entries.subentries.async_init( - (mock_config_entry.entry_id, "tts"), + (mock_config_entry.entry_id, subentry_type), context={"source": config_entries.SOURCE_USER}, ) @@ -214,75 +330,52 @@ async def test_creating_tts_subentry( assert result["step_id"] == "set_options" assert not result["errors"] - old_subentries = set(mock_config_entry.subentries) - + # Uncheck recommended to show custom options with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_NAME: "Mock TTS", **RECOMMENDED_TTS_OPTIONS}, + result["data_schema"]({CONF_RECOMMENDED: False}), ) - await hass.async_block_till_done() + assert result2["type"] is FlowResultType.FORM - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Mock TTS" - assert result2["data"] == RECOMMENDED_TTS_OPTIONS + # Find the schema key for CONF_CHAT_MODEL and check its default + schema_dict = result2["data_schema"].schema + chat_model_key = next(key for key in schema_dict if key.schema == CONF_CHAT_MODEL) + assert chat_model_key.default() == recommended_model + models_in_selector = [ + opt["value"] for opt in schema_dict[chat_model_key].config["options"] + ] + assert recommended_model in models_in_selector - assert len(mock_config_entry.subentries) == 4 - - new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] - new_subentry = mock_config_entry.subentries[new_subentry_id] - - assert new_subentry.subentry_type == "tts" - assert new_subentry.data == RECOMMENDED_TTS_OPTIONS - assert new_subentry.title == "Mock TTS" - - -async def test_creating_ai_task_subentry( - hass: HomeAssistant, - mock_init_component: None, - mock_config_entry: MockConfigEntry, -) -> None: - """Test creating an AI task subentry.""" + # Submit the form with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): - result = await hass.config_entries.subentries.async_init( - (mock_config_entry.entry_id, "ai_task_data"), - context={"source": config_entries.SOURCE_USER}, - ) - - assert result["type"] is FlowResultType.FORM, result - assert result["step_id"] == "set_options" - assert not result["errors"] - - old_subentries = set(mock_config_entry.subentries) - - with patch( - "google.genai.models.AsyncModels.list", - return_value=get_models_pager(), - ): - result2 = await hass.config_entries.subentries.async_configure( - result["flow_id"], - {CONF_NAME: "Mock AI Task", **RECOMMENDED_AI_TASK_OPTIONS}, + result3 = await hass.config_entries.subentries.async_configure( + result2["flow_id"], + result2["data_schema"]({CONF_NAME: "Mock name", **options}), ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Mock AI Task" - assert result2["data"] == RECOMMENDED_AI_TASK_OPTIONS + expected_options = options.copy() + if CONF_PROMPT in expected_options: + expected_options[CONF_PROMPT] = expected_options[CONF_PROMPT].strip() + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Mock name" + assert result3["data"] == expected_options - assert len(mock_config_entry.subentries) == 4 + assert len(mock_config_entry.subentries) == len(old_subentries) + 1 new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] new_subentry = mock_config_entry.subentries[new_subentry_id] - assert new_subentry.subentry_type == "ai_task_data" - assert new_subentry.data == RECOMMENDED_AI_TASK_OPTIONS - assert new_subentry.title == "Mock AI Task" + assert new_subentry.subentry_type == subentry_type + assert new_subentry.data == expected_options + assert new_subentry.title == "Mock name" async def test_creating_conversation_subentry_not_loaded( diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index ff9694257f9..90f496b4b5b 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -359,7 +359,7 @@ async def test_empty_response( assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - ERROR_GETTING_RESPONSE + "Unable to get response" ) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index e154f9d33c9..fbd52dc9245 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -11,11 +11,13 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.google_generative_ai_conversation.const import ( DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CONVERSATION_OPTIONS, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TTS_OPTIONS, ) from homeassistant.config_entries import ( @@ -489,7 +491,7 @@ async def test_migration_from_v1( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -516,6 +518,14 @@ async def test_migration_from_v1( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME subentry = conversation_subentries[0] @@ -721,7 +731,7 @@ async def test_migration_from_v1_disabled( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -748,6 +758,14 @@ async def test_migration_from_v1_disabled( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME assert not device_registry.async_get_device( identifiers={(DOMAIN, mock_config_entry.entry_id)} @@ -860,7 +878,7 @@ async def test_migration_from_v1_with_multiple_keys( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 3 + assert len(entry.subentries) == 4 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -873,6 +891,10 @@ async def test_migration_from_v1_with_multiple_keys( assert subentry.subentry_type == "ai_task_data" assert subentry.data == RECOMMENDED_AI_TASK_OPTIONS assert subentry.title == DEFAULT_AI_TASK_NAME + subentry = list(entry.subentries.values())[3] + assert subentry.subentry_type == "stt" + assert subentry.data == RECOMMENDED_STT_OPTIONS + assert subentry.title == DEFAULT_STT_NAME dev = device_registry.async_get_device( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} @@ -963,7 +985,7 @@ async def test_migration_from_v1_with_same_keys( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -990,6 +1012,14 @@ async def test_migration_from_v1_with_same_keys( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME subentry = conversation_subentries[0] @@ -1090,10 +1120,11 @@ async def test_migration_from_v2_1( """Test migration from version 2.1. This tests we clean up the broken migration in Home Assistant Core - 2025.7.0b0-2025.7.0b1 and add AI Task subentry: + 2025.7.0b0-2025.7.0b1 and add AI Task and STT subentries: - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) - Add TTS subentry (Added in Home Assistant Core 2025.7.0b1) - Add AI Task subentry (Added in version 2.3) + - Add STT subentry (Added in version 2.3) """ # Create a v2.1 config entry with 2 subentries, devices and entities options = { @@ -1184,7 +1215,7 @@ async def test_migration_from_v2_1( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -1211,6 +1242,14 @@ async def test_migration_from_v2_1( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME subentry = conversation_subentries[0] @@ -1320,8 +1359,8 @@ async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: assert entry.version == 2 assert entry.minor_version == 4 - # Check we now have conversation, tts and ai_task_data subentries - assert len(entry.subentries) == 3 + # Check we now have conversation, tts, stt, and ai_task_data subentries + assert len(entry.subentries) == 4 subentries = { subentry.subentry_type: subentry for subentry in entry.subentries.values() @@ -1336,6 +1375,12 @@ async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME assert ai_task_subentry.data == RECOMMENDED_AI_TASK_OPTIONS + # Find and verify the stt subentry + ai_task_subentry = subentries["stt"] + assert ai_task_subentry is not None + assert ai_task_subentry.title == DEFAULT_STT_NAME + assert ai_task_subentry.data == RECOMMENDED_STT_OPTIONS + # Verify conversation subentry is still there and unchanged conversation_subentry = subentries["conversation"] assert conversation_subentry is not None diff --git a/tests/components/google_generative_ai_conversation/test_stt.py b/tests/components/google_generative_ai_conversation/test_stt.py new file mode 100644 index 00000000000..90c58ebba16 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_stt.py @@ -0,0 +1,303 @@ +"""Tests for the Google Generative AI Conversation STT entity.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable, Generator +from unittest.mock import AsyncMock, Mock, patch + +from google.genai import types +import pytest + +from homeassistant.components import stt +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + DEFAULT_STT_PROMPT, + DOMAIN, + RECOMMENDED_STT_MODEL, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from . import API_ERROR_500, CLIENT_ERROR_BAD_REQUEST + +from tests.common import MockConfigEntry + +TEST_CHAT_MODEL = "models/gemini-2.5-flash" +TEST_PROMPT = "Please transcribe the audio." + + +async def _async_get_audio_stream(data: bytes) -> AsyncIterable[bytes]: + """Yield the audio data.""" + yield data + + +@pytest.fixture +def mock_genai_client() -> Generator[AsyncMock]: + """Mock genai.Client.""" + client = Mock() + client.aio.models.get = AsyncMock() + client.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "This is a test transcription."}], + "role": "model", + } + } + ] + ) + ) + with patch( + "homeassistant.components.google_generative_ai_conversation.Client", + return_value=client, + ) as mock_client: + yield mock_client.return_value + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Set up the test environment.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + + sub_entry = ConfigSubentry( + data={ + CONF_CHAT_MODEL: TEST_CHAT_MODEL, + CONF_PROMPT: TEST_PROMPT, + }, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + + config_entry.runtime_data = mock_genai_client + + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_integration") +async def test_stt_entity_properties(hass: HomeAssistant) -> None: + """Test STT entity properties.""" + entity: stt.SpeechToTextEntity = hass.data[stt.DOMAIN].get_entity( + "stt.google_ai_stt" + ) + assert entity is not None + assert isinstance(entity.supported_languages, list) + assert stt.AudioFormats.WAV in entity.supported_formats + assert stt.AudioFormats.OGG in entity.supported_formats + assert stt.AudioCodecs.PCM in entity.supported_codecs + assert stt.AudioCodecs.OPUS in entity.supported_codecs + assert stt.AudioBitRates.BITRATE_16 in entity.supported_bit_rates + assert stt.AudioSampleRates.SAMPLERATE_16000 in entity.supported_sample_rates + assert stt.AudioChannels.CHANNEL_MONO in entity.supported_channels + + +@pytest.mark.parametrize( + ("audio_format", "call_convert_to_wav"), + [ + (stt.AudioFormats.WAV, True), + (stt.AudioFormats.OGG, False), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_success( + hass: HomeAssistant, + mock_genai_client: AsyncMock, + audio_format: stt.AudioFormats, + call_convert_to_wav: bool, +) -> None: + """Test STT processing audio stream successfully.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=audio_format, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + with patch( + "homeassistant.components.google_generative_ai_conversation.stt.convert_to_wav", + return_value=b"converted_wav_bytes", + ) as mock_convert_to_wav: + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.SUCCESS + assert result.text == "This is a test transcription." + + if call_convert_to_wav: + mock_convert_to_wav.assert_called_once_with( + b"test_audio_bytes", "audio/L16;rate=16000" + ) + else: + mock_convert_to_wav.assert_not_called() + + mock_genai_client.aio.models.generate_content.assert_called_once() + call_args = mock_genai_client.aio.models.generate_content.call_args + assert call_args.kwargs["model"] == TEST_CHAT_MODEL + + contents = call_args.kwargs["contents"] + assert contents[0] == TEST_PROMPT + assert isinstance(contents[1], types.Part) + assert contents[1].inline_data.mime_type == f"audio/{audio_format.value}" + if call_convert_to_wav: + assert contents[1].inline_data.data == b"converted_wav_bytes" + else: + assert contents[1].inline_data.data == b"test_audio_bytes" + + +@pytest.mark.parametrize( + "side_effect", + [ + API_ERROR_500, + CLIENT_ERROR_BAD_REQUEST, + ValueError("Test value error"), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_api_error( + hass: HomeAssistant, + mock_genai_client: AsyncMock, + side_effect: Exception, +) -> None: + """Test STT processing audio stream with API errors.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + mock_genai_client.aio.models.generate_content.side_effect = side_effect + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_empty_response( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test STT processing with an empty response from the API.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + mock_genai_client.aio.models.generate_content.return_value = ( + types.GenerateContentResponse(candidates=[]) + ) + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +@pytest.mark.usefixtures("mock_genai_client") +async def test_stt_uses_default_prompt( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test that the default prompt is used if none is configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + config_entry.runtime_data = mock_genai_client + + # Subentry with no prompt + sub_entry = ConfigSubentry( + data={CONF_CHAT_MODEL: TEST_CHAT_MODEL}, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + await entity.async_process_audio_stream(metadata, audio_stream) + + call_args = mock_genai_client.aio.models.generate_content.call_args + contents = call_args.kwargs["contents"] + assert contents[0] == DEFAULT_STT_PROMPT + + +@pytest.mark.usefixtures("mock_genai_client") +async def test_stt_uses_default_model( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test that the default model is used if none is configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + config_entry.runtime_data = mock_genai_client + + # Subentry with no model + sub_entry = ConfigSubentry( + data={CONF_PROMPT: TEST_PROMPT}, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + await entity.async_process_audio_stream(metadata, audio_stream) + + call_args = mock_genai_client.aio.models.generate_content.call_args + assert call_args.kwargs["model"] == RECOMMENDED_STT_MODEL diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index e3a01c05eca..49ad71f5b6b 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -199,7 +199,8 @@ async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> No }, }, ), - ] + ], + any_order=True, ) diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index c58a12ad007..170fbe7ad82 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -63,8 +63,6 @@ 'stay_out_zones': True, 'work_areas': True, }), - 'messages': list([ - ]), 'metadata': dict({ 'connected': True, 'status_dateteime': '2023-06-05T00:00:00+00:00', diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 109e6614545..0fe46c24254 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -585,6 +585,66 @@ 'state': '40', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_inactive_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_inactive_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inactive reason', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'inactive_reason', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_inactive_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_inactive_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 1 Inactive reason', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_inactive_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- # name: test_sensor_snapshot[sensor.test_mower_1_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index f54250a3336..81874cea8a7 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -312,8 +312,9 @@ async def test_coordinator_automatic_registry_cleanup( dr.async_entries_for_config_entry(device_registry, entry.entry_id) ) # Remove mower 2 and check if it worked - mower2 = values.pop("1234") - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + mower2 = values_copy.pop("1234") + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -327,8 +328,9 @@ async def test_coordinator_automatic_registry_cleanup( == current_devices - 1 ) # Add mower 2 and check if it worked - values["1234"] = mower2 - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + values_copy["1234"] = mower2 + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -342,8 +344,9 @@ async def test_coordinator_automatic_registry_cleanup( ) # Remove mower 1 and check if it worked - mower1 = values.pop(TEST_MOWER_ID) - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + mower1 = values_copy.pop(TEST_MOWER_ID) + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -357,11 +360,9 @@ async def test_coordinator_automatic_registry_cleanup( == current_devices - 1 ) # Add mower 1 and check if it worked - values[TEST_MOWER_ID] = mower1 - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() + values_copy = deepcopy(values) + values_copy[TEST_MOWER_ID] = mower1 + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -461,7 +462,13 @@ async def test_add_and_remove_work_area( poll_values[TEST_MOWER_ID].work_area_names.remove("Front lawn") del poll_values[TEST_MOWER_ID].work_area_dict[123456] del poll_values[TEST_MOWER_ID].work_areas[123456] - del poll_values[TEST_MOWER_ID].calendar.tasks[:2] + + poll_values[TEST_MOWER_ID].calendar.tasks = [ + task + for task in poll_values[TEST_MOWER_ID].calendar.tasks + if task.work_area_id not in [1, 123456] + ] + poll_values[TEST_MOWER_ID].mower.work_area_id = 654321 mock_automower_client.get_status.return_value = poll_values freezer.tick(SCAN_INTERVAL) diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index b1029f5919b..d756b1b2ffa 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -10,7 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -39,7 +39,7 @@ async def test_sensor_unknown_states( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.test_mower_1_mode") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE async def test_cutting_blade_usage_time_sensor( @@ -78,7 +78,7 @@ async def test_next_start_sensor( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.test_mower_1_next_start") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE async def test_work_area_sensor( diff --git a/tests/components/huum/__init__.py b/tests/components/huum/__init__.py index 443cbd52c36..d280bab6a59 100644 --- a/tests/components/huum/__init__.py +++ b/tests/components/huum/__init__.py @@ -1 +1,18 @@ """Tests for the huum integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the Huum integration with the selected platforms.""" + entry.add_to_hass(hass) + with patch("homeassistant.components.huum.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/huum/conftest.py b/tests/components/huum/conftest.py new file mode 100644 index 00000000000..023abd4429e --- /dev/null +++ b/tests/components/huum/conftest.py @@ -0,0 +1,72 @@ +"""Configuration for Huum tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from huum.const import SaunaStatus +import pytest + +from homeassistant.components.huum.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_huum() -> Generator[AsyncMock]: + """Mock data from the API.""" + huum = AsyncMock() + with ( + patch( + "homeassistant.components.huum.config_flow.Huum.status", + return_value=huum, + ), + patch( + "homeassistant.components.huum.coordinator.Huum.status", + return_value=huum, + ), + patch( + "homeassistant.components.huum.coordinator.Huum.turn_on", + return_value=huum, + ) as turn_on, + ): + huum.status = SaunaStatus.ONLINE_NOT_HEATING + huum.door_closed = True + huum.temperature = 30 + huum.sauna_name = 123456 + huum.target_temperature = 80 + huum.light = 1 + huum.humidity = 5 + huum.sauna_config.child_lock = "OFF" + huum.sauna_config.max_heating_time = 3 + huum.sauna_config.min_heating_time = 0 + huum.sauna_config.max_temp = 110 + huum.sauna_config.min_temp = 40 + huum.sauna_config.max_timer = 0 + huum.sauna_config.min_timer = 0 + huum.turn_on = turn_on + + yield huum + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.huum.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "huum@sauna.org", + CONF_PASSWORD: "ukuuku", + }, + unique_id="123456", + entry_id="AABBCC112233", + ) diff --git a/tests/components/huum/snapshots/test_binary_sensor.ambr b/tests/components/huum/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..3490ff594b6 --- /dev/null +++ b/tests/components/huum/snapshots/test_binary_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.huum_sauna_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.huum_sauna_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AABBCC112233_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.huum_sauna_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Huum sauna Door', + }), + 'context': , + 'entity_id': 'binary_sensor.huum_sauna_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/huum/snapshots/test_climate.ambr b/tests/components/huum/snapshots/test_climate.ambr new file mode 100644 index 00000000000..f18fd279f25 --- /dev/null +++ b/tests/components/huum/snapshots/test_climate.ambr @@ -0,0 +1,68 @@ +# serializer version: 1 +# name: test_climate_entity[climate.huum_sauna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 110, + 'min_temp': 40, + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.huum_sauna', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator-off', + 'original_name': None, + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'AABBCC112233', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entity[climate.huum_sauna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30, + 'friendly_name': 'Huum sauna', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator-off', + 'max_temp': 110, + 'min_temp': 40, + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 80, + }), + 'context': , + 'entity_id': 'climate.huum_sauna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/huum/test_binary_sensor.py b/tests/components/huum/test_binary_sensor.py new file mode 100644 index 00000000000..5ea2ae69a11 --- /dev/null +++ b/tests/components/huum/test_binary_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the Huum climate entity.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "binary_sensor.huum_sauna_door" + + +async def test_binary_sensor( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.BINARY_SENSOR] + ) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/huum/test_climate.py b/tests/components/huum/test_climate.py new file mode 100644 index 00000000000..ca7fcf81185 --- /dev/null +++ b/tests/components/huum/test_climate.py @@ -0,0 +1,78 @@ +"""Tests for the Huum climate entity.""" + +from unittest.mock import AsyncMock + +from huum.const import SaunaStatus +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "climate.huum_sauna" + + +async def test_climate_entity( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_huum.status = SaunaStatus.ONLINE_HEATING + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.HEAT + + mock_huum.turn_on.assert_called_once() + + +async def test_set_temperature( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the temperature.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_huum.status = SaunaStatus.ONLINE_HEATING + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 60, + }, + blocking=True, + ) + + mock_huum.turn_on.assert_called_once_with(60) diff --git a/tests/components/huum/test_config_flow.py b/tests/components/huum/test_config_flow.py index 9917f71fc08..d59eac51207 100644 --- a/tests/components/huum/test_config_flow.py +++ b/tests/components/huum/test_config_flow.py @@ -1,6 +1,6 @@ """Test the huum config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from huum.exceptions import Forbidden import pytest @@ -13,11 +13,13 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -TEST_USERNAME = "test-username" -TEST_PASSWORD = "test-password" +TEST_USERNAME = "huum@sauna.org" +TEST_PASSWORD = "ukuuku" -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, mock_huum: AsyncMock, mock_setup_entry: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -26,24 +28,14 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_USERNAME @@ -54,42 +46,28 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None: +async def test_signup_flow_already_set_up( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test that we handle already existing entities with same id.""" - mock_config_entry = MockConfigEntry( - title="Huum Sauna", - domain=DOMAIN, - unique_id=TEST_USERNAME, - data={ - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.ABORT @pytest.mark.parametrize( @@ -103,7 +81,11 @@ async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None: ], ) async def test_huum_errors( - hass: HomeAssistant, raises: Exception, error_base: str + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_setup_entry: AsyncMock, + raises: Exception, + error_base: str, ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( @@ -125,21 +107,11 @@ async def test_huum_errors( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": error_base} - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/huum/test_init.py b/tests/components/huum/test_init.py new file mode 100644 index 00000000000..fac5fa875ee --- /dev/null +++ b/tests/components/huum/test_init.py @@ -0,0 +1,27 @@ +"""Tests for the Huum __init__.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.huum.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry + + +async def test_loading_and_unloading_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_huum: AsyncMock +) -> None: + """Test loading and unloading a config entry.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index e0b091e5ff3..0ba09c27e0e 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, patch -from imgw_pib import HydrologicalData, SensorData +from imgw_pib import Alert, HydrologicalData, SensorData import pytest from homeassistant.components.imgw_pib.const import DOMAIN @@ -25,6 +25,13 @@ HYDROLOGICAL_DATA = HydrologicalData( water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), water_flow=SensorData(name="Water Flow", value=123.45), water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), + hydrological_alert=Alert( + value="rapid_water_level_rise", + valid_from=datetime(2024, 4, 27, 7, 0, tzinfo=UTC), + valid_to=datetime(2024, 4, 28, 11, 0, tzinfo=UTC), + level="yellow", + probability=80, + ), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 08f3690136e..420a9300d3d 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -34,6 +34,13 @@ 'unit': None, 'value': None, }), + 'hydrological_alert': dict({ + 'level': 'yellow', + 'probability': 80, + 'valid_from': '2024-04-27T07:00:00+00:00', + 'valid_to': '2024-04-28T11:00:00+00:00', + 'value': 'rapid_water_level_rise', + }), 'latitude': None, 'longitude': None, 'river': 'River Name', diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 97bb6eefef3..276ea41eecf 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,4 +1,69 @@ # serializer version: 1 +# name: test_sensor[sensor.river_name_station_name_hydrological_alert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_alert', + 'hydrological_drought', + 'rapid_water_level_rise', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.river_name_station_name_hydrological_alert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrological alert', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hydrological_alert', + 'unique_id': '123_hydrological_alert', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.river_name_station_name_hydrological_alert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'enum', + 'friendly_name': 'River Name (Station Name) Hydrological alert', + 'level': 'yellow', + 'options': list([ + 'no_alert', + 'hydrological_drought', + 'rapid_water_level_rise', + ]), + 'probability': 80, + 'valid_from': datetime.datetime(2024, 4, 27, 7, 0, tzinfo=datetime.timezone.utc), + 'valid_to': datetime.datetime(2024, 4, 28, 11, 0, tzinfo=datetime.timezone.utc), + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_hydrological_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rapid_water_level_rise', + }) +# --- # name: test_sensor[sensor.river_name_station_name_water_flow-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index b2e99836477..b82bbe59203 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -54,12 +54,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_methods(hass: HomeAssistant) -> None: diff --git a/tests/components/input_button/test_init.py b/tests/components/input_button/test_init.py index e59d0543751..78cfd0a3d8b 100644 --- a/tests/components/input_button/test_init.py +++ b/tests/components/input_button/test_init.py @@ -47,12 +47,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 8ea1c2e25b6..94166a8ab7e 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -98,16 +98,19 @@ async def decrement(hass: HomeAssistant, entity_id: str) -> None: ) -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, {"test_1": {"min": 50, "max": 50}}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + {"test_1": {"min": 0, "max": 10, "initial": 11}}, + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" + + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_set_value(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 153d8ed848d..c53e105bd09 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -70,17 +70,18 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, {"bad_initial": {"options": [1, 2], "initial": 3}}, - ] + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_select_option(hass: HomeAssistant) -> None: diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index 2ca1d39a983..c0c18a5153c 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -81,16 +81,21 @@ async def async_set_value(hass: HomeAssistant, entity_id: str, value: str) -> No ) -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, - {"test_1": {"min": 50, "max": 50}}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + {"test_1": {"min": 51, "max": 50}}, + {"test_1": {"min": -1, "max": 100}}, + {"test_1": {"min": 0, "max": 256}}, + {"test_1": {"min": 0, "max": 3, "initial": "aaaaa"}}, + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" + + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_set_value(hass: HomeAssistant) -> None: diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index ba4a6bdf198..3d5549d88bf 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -294,23 +294,35 @@ async def test_restore_state_failed(hass: HomeAssistant, extra_attributes) -> No assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( "sequence", [ + # time, value, attributes, expected ( - (20, 10, 1.67), - (30, 30, 5.0), - (40, 5, 7.92), - (50, 5, 8.75), - (60, 0, 9.17), + (0, 0, {}, 0), + (20, 10, {}, 1.67), + (30, 30, {}, 5.0), + (40, 5, {}, 7.92), + (50, 5, {}, 8.75), # This fires a state report + (60, 0, {}, 9.17), + ), + ( + (0, 0, {}, 0), + (20, 10, {}, 1.67), + (30, 30, {}, 5.0), + (40, 5, {}, 7.92), + (50, 5, {"foo": "bar"}, 8.75), # This fires a state change + (60, 0, {}, 9.17), ), ], ) async def test_trapezoidal( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], ) -> None: """Test integration sensor state.""" config = { @@ -320,23 +332,29 @@ async def test_trapezoidal( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set(entity_id, 0, {}) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: # Testing a power sensor with non-monotonic intervals and values - for time, value, expected in sequence: + for time, value, extra_attributes, expected in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) await hass.async_block_till_done() @@ -346,25 +364,35 @@ async def test_trapezoidal( assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( "sequence", [ + # time, value, attributes, expected ( - (20, 10, 0.0), - (30, 30, 1.67), - (40, 5, 6.67), - (50, 5, 7.5), - (60, 0, 8.33), + (20, 10, {}, 0.0), + (30, 30, {}, 1.67), + (40, 5, {}, 6.67), + (50, 5, {}, 7.5), # This fires a state report + (60, 0, {}, 8.33), + ), + ( + (20, 10, {}, 0.0), + (30, 30, {}, 1.67), + (40, 5, {}, 6.67), + (50, 5, {"foo": "bar"}, 7.5), # This fires a state change + (60, 0, {}, 8.33), ), ], ) async def test_left( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], ) -> None: - """Test integration sensor state with left reimann method.""" + """Test integration sensor state with left Riemann method.""" config = { "sensor": { "platform": "integration", @@ -373,25 +401,31 @@ async def test_left( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set( entity_id, 0, {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} ) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN # Testing a power sensor with non-monotonic intervals and values start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: - for time, value, expected in sequence: + for time, value, extra_attributes, expected in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) await hass.async_block_till_done() @@ -401,25 +435,34 @@ async def test_left( assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( "sequence", [ ( - (20, 10, 3.33), - (30, 30, 8.33), - (40, 5, 9.17), - (50, 5, 10.0), - (60, 0, 10.0), + (20, 10, {}, 3.33), + (30, 30, {}, 8.33), + (40, 5, {}, 9.17), + (50, 5, {}, 10.0), # This fires a state report + (60, 0, {}, 10.0), + ), + ( + (20, 10, {}, 3.33), + (30, 30, {}, 8.33), + (40, 5, {}, 9.17), + (50, 5, {"foo": "bar"}, 10.0), # This fires a state change + (60, 0, {}, 10.0), ), ], ) async def test_right( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], ) -> None: - """Test integration sensor state with left reimann method.""" + """Test integration sensor state with right Riemann method.""" config = { "sensor": { "platform": "integration", @@ -428,25 +471,31 @@ async def test_right( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set( entity_id, 0, {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} ) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN # Testing a power sensor with non-monotonic intervals and values start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: - for time, value, expected in sequence: + for time, value, extra_attributes, expected in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) await hass.async_block_till_done() diff --git a/tests/components/jellyfin/conftest.py b/tests/components/jellyfin/conftest.py index c3732714177..71088dea2ea 100644 --- a/tests/components/jellyfin/conftest.py +++ b/tests/components/jellyfin/conftest.py @@ -81,6 +81,7 @@ def mock_api() -> MagicMock: jf_api.get_item.side_effect = api_get_item_side_effect jf_api.get_media_folders.return_value = load_json_fixture("get-media-folders.json") jf_api.user_items.side_effect = api_user_items_side_effect + jf_api.search_media_items.return_value = load_json_fixture("user-items.json") return jf_api diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 404fdc801ee..b4506f5a607 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -363,6 +363,47 @@ async def test_browse_media( ) +async def test_search_media( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test Jellyfin browse media.""" + client = await hass_ws_client() + + # browse root folder + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.jellyfin_device", + "media_content_id": "", + "media_content_type": "", + "search_query": "Fake Item 1", + "media_filter_classes": ["movie"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["result"] == [ + { + "title": "FOLDER", + "media_class": MediaClass.DIRECTORY.value, + "media_content_type": "string", + "media_content_id": "FOLDER-UUID", + "children_media_class": None, + "can_play": False, + "can_expand": True, + "can_search": False, + "not_shown": 0, + "thumbnail": "http://localhost/Items/21af9851-8e39-43a9-9c47-513d3b9e99fc/Images/Primary.jpg", + "children": [], + } + ] + + async def test_new_client_connected( hass: HomeAssistant, init_integration: MockConfigEntry, diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 25974b2d78a..6d59f8bd2ef 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,7 +3,12 @@ from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch -from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus +from letpot.models import ( + DeviceFeature, + LetPotDevice, + LetPotDeviceInfo, + LetPotDeviceStatus, +) import pytest from homeassistant.components.letpot.const import ( @@ -26,6 +31,16 @@ def device_type() -> str: return "LPH63" +def _mock_device_info(device_type: str) -> LetPotDeviceInfo: + """Return mock device info for the given type.""" + return LetPotDeviceInfo( + model=device_type, + model_name=f"LetPot {device_type}", + model_code=device_type, + features=_mock_device_features(device_type), + ) + + def _mock_device_features(device_type: str) -> DeviceFeature: """Return mock device feature support for the given type.""" if device_type == "LPH31": @@ -89,32 +104,33 @@ def mock_client(device_type: str) -> Generator[AsyncMock]: @pytest.fixture -def mock_device_client(device_type: str) -> Generator[AsyncMock]: +def mock_device_client() -> Generator[AsyncMock]: """Mock a LetPotDeviceClient.""" with patch( - "homeassistant.components.letpot.coordinator.LetPotDeviceClient", + "homeassistant.components.letpot.LetPotDeviceClient", autospec=True, ) as mock_device_client: device_client = mock_device_client.return_value - device_client.device_features = _mock_device_features(device_type) - device_client.device_model_code = device_type - device_client.device_model_name = f"LetPot {device_type}" - device_status = _mock_device_status(device_type) - subscribe_callbacks: list[Callable] = [] + subscribe_callbacks: dict[str, Callable] = {} - def subscribe_side_effect(callback: Callable) -> None: - subscribe_callbacks.append(callback) + def subscribe_side_effect(serial: str, callback: Callable) -> None: + subscribe_callbacks[serial] = callback - def status_side_effect() -> None: - # Deliver a status update to any subscribers, like the real client - for callback in subscribe_callbacks: - callback(device_status) + def request_status_side_effect(serial: str) -> None: + # Deliver a status update to the subscriber, like the real client + if (callback := subscribe_callbacks.get(serial)) is not None: + callback(_mock_device_status(serial[:5])) - device_client.get_current_status.side_effect = status_side_effect - device_client.get_current_status.return_value = device_status - device_client.last_status.return_value = device_status - device_client.request_status_update.side_effect = status_side_effect + def get_current_status_side_effect(serial: str) -> LetPotDeviceStatus: + request_status_side_effect(serial) + return _mock_device_status(serial[:5]) + + device_client.device_info.side_effect = lambda serial: _mock_device_info( + serial[:5] + ) + device_client.get_current_status.side_effect = get_current_status_side_effect + device_client.request_status_update.side_effect = request_status_side_effect device_client.subscribe.side_effect = subscribe_side_effect yield device_client diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py index e3f78d87dc1..8357b4da67e 100644 --- a/tests/components/letpot/test_init.py +++ b/tests/components/letpot/test_init.py @@ -37,7 +37,7 @@ async def test_load_unload_config_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - mock_device_client.disconnect.assert_called_once() + mock_device_client.unsubscribe.assert_called_once() @pytest.mark.freeze_time("2025-02-15 00:00:00") diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index 7eeafd78291..b1b4b48b7bb 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -58,6 +58,7 @@ async def test_set_switch( mock_config_entry: MockConfigEntry, mock_client: MagicMock, mock_device_client: MagicMock, + device_type: str, service: str, parameter_value: bool, ) -> None: @@ -71,7 +72,9 @@ async def test_set_switch( target={"entity_id": "switch.garden_power"}, ) - mock_device_client.set_power.assert_awaited_once_with(parameter_value) + mock_device_client.set_power.assert_awaited_once_with( + f"{device_type}ABCD", parameter_value + ) @pytest.mark.parametrize( diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index dba51ce8497..5c84b6a0159 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -38,6 +38,7 @@ async def test_set_time( mock_config_entry: MockConfigEntry, mock_client: MagicMock, mock_device_client: MagicMock, + device_type: str, ) -> None: """Test setting the time entity.""" await setup_integration(hass, mock_config_entry) @@ -50,7 +51,9 @@ async def test_set_time( target={"entity_id": "time.garden_light_on"}, ) - mock_device_client.set_light_schedule.assert_awaited_once_with(time(7, 0), None) + mock_device_client.set_light_schedule.assert_awaited_once_with( + f"{device_type}ABCD", time(7, 0), None + ) @pytest.mark.parametrize( diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py index 5fd6b11c8fe..bfa8ad3a0ef 100644 --- a/tests/components/mold_indicator/test_init.py +++ b/tests/components/mold_indicator/test_init.py @@ -2,12 +2,190 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from unittest.mock import patch + +import pytest + +from homeassistant.components import mold_indicator +from homeassistant.components.mold_indicator.config_flow import ( + MoldIndicatorConfigFlowHandler, +) +from homeassistant.components.mold_indicator.const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_NAME +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def indoor_humidity_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def indoor_humidity_device( + device_registry: dr.DeviceRegistry, indoor_humidity_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=indoor_humidity_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:ED")}, + ) + + +@pytest.fixture +def indoor_humidity_entity_entry( + entity_registry: er.EntityRegistry, + indoor_humidity_config_entry: ConfigEntry, + indoor_humidity_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_indoor_humidity", + config_entry=indoor_humidity_config_entry, + device_id=indoor_humidity_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def indoor_temperature_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def indoor_temperature_device( + device_registry: dr.DeviceRegistry, indoor_temperature_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=indoor_temperature_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EE")}, + ) + + +@pytest.fixture +def indoor_temperature_entity_entry( + entity_registry: er.EntityRegistry, + indoor_temperature_config_entry: ConfigEntry, + indoor_temperature_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_indoor_temperature", + config_entry=indoor_temperature_config_entry, + device_id=indoor_temperature_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def outdoor_temperature_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def outdoor_temperature_device( + device_registry: dr.DeviceRegistry, outdoor_temperature_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=outdoor_temperature_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def outdoor_temperature_entity_entry( + entity_registry: er.EntityRegistry, + outdoor_temperature_config_entry: ConfigEntry, + outdoor_temperature_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_outdoor_temperature", + config_entry=outdoor_temperature_config_entry, + device_id=outdoor_temperature_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def mold_indicator_config_entry( + hass: HomeAssistant, + indoor_humidity_entity_entry: er.RegistryEntry, + indoor_temperature_entity_entry: er.RegistryEntry, + outdoor_temperature_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a mold_indicator config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "My mold indicator", + CONF_INDOOR_HUMIDITY: indoor_humidity_entity_entry.entity_id, + CONF_INDOOR_TEMP: indoor_temperature_entity_entry.entity_id, + CONF_OUTDOOR_TEMP: outdoor_temperature_entity_entry.entity_id, + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=MoldIndicatorConfigFlowHandler.VERSION, + minor_version=MoldIndicatorConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + indoor_humidity_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return indoor_humidity_device.id if request.param == "humidity_device_id" else None + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + @callback + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: """Test unload an entry.""" @@ -15,3 +193,500 @@ async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) assert await hass.config_entries.async_unload(loaded_entry.entry_id) await hass.async_block_till_done() assert loaded_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device_cleaning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test cleaning of devices linked to the helper config entry.""" + + # Source entity device config entry + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + + # Device entry of the source entity + source_device1_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test1")}, + connections={("mac", "30:31:32:33:34:01")}, + ) + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "indoor", + "humidity", + config_entry=source_config_entry, + device_id=source_device1_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.indoor_humidity") is not None + + # Configure the configuration entry for helper + helper_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="Test", + ) + helper_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the helper entity + helper_entity = entity_registry.async_get("sensor.mold_indicator") + assert helper_entity is not None + assert helper_entity.device_id == source_entity.device_id + + # Device entry incorrectly linked to config entry + device_registry.async_get_or_create( + config_entry_id=helper_config_entry.entry_id, + identifiers={("sensor", "identifier_test2")}, + connections={("mac", "30:31:32:33:34:02")}, + ) + device_registry.async_get_or_create( + config_entry_id=helper_config_entry.entry_id, + identifiers={("sensor", "identifier_test3")}, + connections={("mac", "30:31:32:33:34:03")}, + ) + await hass.async_block_till_done() + + # Before reloading the config entry, 3 devices are expected to be linked + devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id + ) + assert len(devices_before_reload) == 2 + + # Config entry reload + await hass.config_entries.async_reload(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the helper entity + helper_entity = entity_registry.async_get("sensor.mold_indicator") + assert helper_entity is not None + assert helper_entity.device_id == source_entity.device_id + + # After reloading the config entry, only one linked device is expected + devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id + ) + assert len(devices_after_reload) == 0 + + +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", None, ["update"]), + ("sensor.test_unique_indoor_temperature", "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the mold_indicator config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", None, ["update"]), + ("sensor.test_unique_indoor_temperature", "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the mold_indicator config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check if the mold_indicator config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("sensor.test_unique_indoor_humidity", 1, None, ["update"]), + ("sensor.test_unique_indoor_temperature", 0, "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", 0, "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + unload_entry_calls: int, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the source entity removed from the source device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity from the device + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check that the mold_indicator config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "unload_entry_calls", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", 1, ["update"]), + ("sensor.test_unique_indoor_temperature", 0, []), + ("sensor.test_unique_outdoor_temperature", 0, []), + ], +) +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity is moved to another device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + source_device_2 = device_registry.async_get_or_create( + config_entry_id=source_entity_entry.config_entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert mold_indicator_config_entry.entry_id not in source_device_2.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Move the source entity to another device + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=source_device_2.id + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the helper entity is linked to the expected source device + indoor_humidity_entity_entry = entity_registry.async_get( + indoor_humidity_entity_entry.entity_id + ) + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + # Check that the mold_indicator config entry is not in any of the devices + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert mold_indicator_config_entry.entry_id not in source_device_2.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "config_key"), + [ + ("sensor.test_unique_indoor_humidity", CONF_INDOOR_HUMIDITY), + ("sensor.test_unique_indoor_temperature", CONF_INDOOR_TEMP), + ("sensor.test_unique_outdoor_temperature", CONF_OUTDOOR_TEMP), + ], +) +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + config_key: str, +) -> None: + """Test the source entity's entity ID is changed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the mold_indicator config entry is updated with the new entity ID + assert mold_indicator_config_entry.options[config_key] == "sensor.new_entity_id" + + # Check that the helper config is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + indoor_humidity_device: dr.DeviceEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + indoor_temperature_entity_entry: er.RegistryEntry, + outdoor_temperature_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes mold_indicator config entry from device.""" + + mold_indicator_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "My mold indicator", + CONF_INDOOR_HUMIDITY: indoor_humidity_entity_entry.entity_id, + CONF_INDOOR_TEMP: indoor_temperature_entity_entry.entity_id, + CONF_OUTDOOR_TEMP: outdoor_temperature_entity_entry.entity_id, + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=1, + minor_version=1, + ) + mold_indicator_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + indoor_humidity_device.id, + add_config_entry_id=mold_indicator_config_entry.entry_id, + ) + + # Check preconditions + switch_device = device_registry.async_get(indoor_humidity_device.id) + assert mold_indicator_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + assert mold_indicator_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert mold_indicator_config_entry.entry_id not in switch_device.config_entries + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + assert mold_indicator_config_entry.version == 1 + assert mold_indicator_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=2, + 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 config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 8d942e7a2a1..f3c4820ff90 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -532,7 +532,7 @@ async def test_advanced_options(hass: HomeAssistant) -> None: assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] assert CONF_STREAM_URL_TEMPLATE not in result["data"] assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 result = await hass.config_entries.options.async_init( config_entry.entry_id, context={"show_advanced_options": True} @@ -551,4 +551,4 @@ async def test_advanced_options(hass: HomeAssistant) -> None: assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] assert result["data"][CONF_STREAM_URL_TEMPLATE] == "http://moo" assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index bc345c0b66f..4e9d5e926a8 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -116,7 +116,6 @@ async def test_setup_camera_with_wrong_webhook( ) assert not client.async_set_camera.called - # Update the options, which will trigger a reload with the new behavior. with patch( "homeassistant.components.motioneye.MotionEyeClient", return_value=client, @@ -124,6 +123,7 @@ async def test_setup_camera_with_wrong_webhook( hass.config_entries.async_update_entry( config_entry, options={CONF_WEBHOOK_SET_OVERWRITE: True} ) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() device = device_registry.async_get_device( diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 77c74001939..ce0a0c44a79 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3344,6 +3344,7 @@ async def test_subentry_reconfigure_remove_entity( "delete_entity", "device", "availability", + "export", ] # assert we can delete an entity @@ -3465,6 +3466,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "delete_entity", "device", "availability", + "export", ] # assert we can update an entity @@ -3683,6 +3685,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -3823,6 +3826,7 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -3953,6 +3957,7 @@ async def test_subentry_reconfigure_add_entity( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -4058,6 +4063,7 @@ async def test_subentry_reconfigure_update_device_properties( "delete_entity", "device", "availability", + "export", ] # assert we can update the device properties @@ -4214,6 +4220,100 @@ async def test_subentry_reconfigure_availablity( } +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +@pytest.mark.parametrize( + ("flow_step", "field_suggestions"), + [ + ("export_yaml", {"yaml": "identifiers:\n - {}\n"}), + ( + "export_discovery", + { + "discovery_topic": "homeassistant/device/{}/config", + "discovery_payload": '"identifiers": [\n "{}"\n', + }, + ), + ], +) +async def test_subentry_reconfigure_export_settings( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + flow_step: str, + field_suggestions: dict[str, str], +) -> None: + """Test the subentry ConfigFlow reconfigure export feature.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # assert menu options, we have the option to export + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + "export", + ] + + # Open export menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "export"}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "export" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": flow_step}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == flow_step + assert result["description_placeholders"] == { + "url": "https://www.home-assistant.io/integrations/mqtt/" + } + + # Assert the export is correct + for field in result["data_schema"].schema: + assert ( + field_suggestions[field].format(subentry_id) + in field.description["suggested_value"] + ) + + # Back to summary menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + async def test_subentry_configflow_section_feature( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, diff --git a/tests/components/mqtt/test_repairs.py b/tests/components/mqtt/test_repairs.py new file mode 100644 index 00000000000..bc7b9dd4294 --- /dev/null +++ b/tests/components/mqtt/test_repairs.py @@ -0,0 +1,179 @@ +"""Test repairs for MQTT.""" + +from collections.abc import Coroutine +from copy import deepcopy +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData +from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.util.yaml import parse_yaml + +from .common import MOCK_NOTIFY_SUBENTRY_DATA_MULTI, async_fire_mqtt_message + +from tests.common import MockConfigEntry, async_capture_events +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.conftest import ClientSessionGenerator +from tests.typing import MqttMockHAClientGenerator + + +async def help_setup_yaml(hass: HomeAssistant, config: dict[str, str]) -> None: + """Help to set up an exported MQTT device via YAML.""" + with patch( + "homeassistant.config.load_yaml_config_file", + return_value=parse_yaml(config["yaml"]), + ): + await hass.services.async_call( + mqtt.DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def help_setup_discovery(hass: HomeAssistant, config: dict[str, str]) -> None: + """Help to set up an exported MQTT device via YAML.""" + async_fire_mqtt_message( + hass, config["discovery_topic"], config["discovery_payload"] + ) + await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +@pytest.mark.parametrize( + ("flow_step", "setup_helper", "translation_key"), + [ + ("export_yaml", help_setup_yaml, "subentry_migration_yaml"), + ("export_discovery", help_setup_discovery, "subentry_migration_discovery"), + ], +) +async def test_subentry_reconfigure_export_settings( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + hass_client: ClientSessionGenerator, + flow_step: str, + setup_helper: Coroutine[Any, Any, None], + translation_key: str, +) -> None: + """Test the subentry ConfigFlow YAML export with migration to YAML.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {subentry_id} + assert device is not None + + # assert we entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # assert menu options, we have the option to export + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + "export", + ] + + # Open export menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "export"}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "export" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": flow_step}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == flow_step + assert result["description_placeholders"] == { + "url": "https://www.home-assistant.io/integrations/mqtt/" + } + + # Copy the exported config suggested values for an export + suggested_values_from_schema = { + field: field.description["suggested_value"] + for field in result["data_schema"].schema + } + # Try to set up the exported config with a changed device name + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + await setup_helper(hass, suggested_values_from_schema) + + # Assert the subentry device was not effected by the exported configs + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {subentry_id} + assert device is not None + + # Assert a repair flow was created + # This happens when the exported device identifier was detected + # The subentry ID is used as device identifier + assert len(events) == 1 + issue_id = events[0].data["issue_id"] + issue_registry = ir.async_get(hass) + repair_issue = issue_registry.async_get_issue(mqtt.DOMAIN, issue_id) + assert repair_issue.translation_key == translation_key + + await async_process_repairs_platforms(hass) + client = await hass_client() + + data = await start_repair_fix_flow(client, mqtt.DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"name": "Milk notifier"} + assert data["step_id"] == "confirm" + + data = await process_repair_fix_flow(client, flow_id) + assert data["type"] == "create_entry" + + # Assert the subentry is removed and no other entity has linked the device + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is None + + await hass.async_block_till_done(wait_background_tasks=True) + assert len(config_entry.subentries) == 0 + + # Try to set up the exported config again + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + await setup_helper(hass, suggested_values_from_schema) + assert len(events) == 0 + + # The MQTT device was now set up from the new source + await hass.async_block_till_done(wait_background_tasks=True) + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {None} + assert device is not None diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 997c014cd13..16f0c9f22bc 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -924,6 +924,30 @@ async def test_invalid_unit_of_measurement( "device_class": None, "unit_of_measurement": None, }, + { + "name": "Test 4", + "state_topic": "test-topic", + "device_class": "ph", + "unit_of_measurement": "", + }, + { + "name": "Test 5", + "state_topic": "test-topic", + "device_class": "ph", + "unit_of_measurement": " ", + }, + { + "name": "Test 6", + "state_topic": "test-topic", + "device_class": None, + "unit_of_measurement": "", + }, + { + "name": "Test 7", + "state_topic": "test-topic", + "device_class": None, + "unit_of_measurement": " ", + }, ] } } @@ -936,10 +960,25 @@ async def test_valid_device_class_and_uom( await mqtt_mock_entry() state = hass.states.get("sensor.test_1") + assert state is not None assert state.attributes["device_class"] == "temperature" state = hass.states.get("sensor.test_2") + assert state is not None assert "device_class" not in state.attributes state = hass.states.get("sensor.test_3") + assert state is not None + assert "device_class" not in state.attributes + state = hass.states.get("sensor.test_4") + assert state is not None + assert state.attributes["device_class"] == "ph" + state = hass.states.get("sensor.test_5") + assert state is not None + assert state.attributes["device_class"] == "ph" + state = hass.states.get("sensor.test_6") + assert state is not None + assert "device_class" not in state.attributes + state = hass.states.get("sensor.test_7") + assert state is not None assert "device_class" not in state.attributes diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 309c8860c20..06eb94d59d0 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -323,9 +323,6 @@ async def test_options_flow_entity_removal( "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, ), - patch( - "homeassistant.components.nina._async_update_listener" - ) as mock_update_listener, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -352,4 +349,3 @@ async def test_options_flow_entity_removal( ) assert len(entries) == 2 - assert len(mock_update_listener.mock_calls) == 1 diff --git a/tests/components/ollama/test_ai_task.py b/tests/components/ollama/test_ai_task.py index ee812e7b316..cb639db0f8e 100644 --- a/tests/components/ollama/test_ai_task.py +++ b/tests/components/ollama/test_ai_task.py @@ -1,11 +1,13 @@ """Test AI Task platform of Ollama integration.""" +from pathlib import Path from unittest.mock import patch +import ollama import pytest import voluptuous as vol -from homeassistant.components import ai_task +from homeassistant.components import ai_task, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector @@ -243,3 +245,115 @@ async def test_generate_invalid_structured_data( }, ), ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_attachment( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with image attachments.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": {"role": "assistant", "content": "Generated test data"}, + "done": True, + "done_reason": "stop", + } + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + ], + ), + patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ) as mock_chat, + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + ], + ) + + assert result.data == "Generated test data" + + assert mock_chat.call_count == 1 + messages = mock_chat.call_args[1]["messages"] + assert len(messages) == 2 + chat_message = messages[1] + assert chat_message.role == "user" + assert chat_message.content == "Generate test data" + assert chat_message.images == [ + ollama.Image(value=Path("doorbell_snapshot.jpg")), + ] + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_unsupported_file_format( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with image attachments.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": {"role": "assistant", "content": "Generated test data"}, + "done": True, + "done_reason": "stop", + } + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ), + pytest.raises( + HomeAssistantError, + match="Ollama only supports image attachments in user content", + ), + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 0748481c40b..ace7afb5645 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,6 +1,5 @@ """Tests for 1-Wire config flow.""" -from copy import deepcopy from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -63,27 +62,6 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) assert config_entry.state is ConfigEntryState.NOT_LOADED -async def test_update_options( - hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock -) -> None: - """Test update options triggers reload.""" - 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 owproxy.call_count == 1 - - new_options = deepcopy(dict(config_entry.options)) - new_options["device_options"].clear() - hass.config_entries.async_update_entry(config_entry, options=new_options) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.LOADED - assert owproxy.call_count == 2 - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_registry( hass: HomeAssistant, diff --git a/tests/components/onkyo/test_init.py b/tests/components/onkyo/test_init.py index 17086a3088e..4c6ddcca214 100644 --- a/tests/components/onkyo/test_init.py +++ b/tests/components/onkyo/test_init.py @@ -33,26 +33,6 @@ async def test_load_unload_entry( assert config_entry.state is ConfigEntryState.NOT_LOADED -async def test_update_entry( - hass: HomeAssistant, - config_entry: MockConfigEntry, -) -> None: - """Test update options.""" - - with patch.object(hass.config_entries, "async_reload", return_value=True): - config_entry = create_empty_config_entry() - receiver_info = create_receiver_info(1) - await setup_integration(hass, config_entry, receiver_info) - - # Force option change - assert hass.config_entries.async_update_entry( - config_entry, options={"option": "new_value"} - ) - await hass.async_block_till_done() - - hass.config_entries.async_reload.assert_called_with(config_entry.entry_id) - - async def test_no_connection( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/open_router/__init__.py b/tests/components/open_router/__init__.py new file mode 100644 index 00000000000..3858e866315 --- /dev/null +++ b/tests/components/open_router/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the OpenRouter integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/open_router/conftest.py b/tests/components/open_router/conftest.py new file mode 100644 index 00000000000..ca679c2ebef --- /dev/null +++ b/tests/components/open_router/conftest.py @@ -0,0 +1,150 @@ +"""Fixtures for OpenRouter integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from openai.types import CompletionUsage +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest + +from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.open_router.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def enable_assist() -> bool: + """Mock conversation subentry data.""" + return False + + +@pytest.fixture +def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: + """Mock conversation subentry data.""" + res: dict[str, Any] = { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "You are a helpful assistant.", + } + if enable_assist: + res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST] + return res + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, conversation_subentry_data: dict[str, Any] +) -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="OpenRouter", + domain=DOMAIN, + data={ + CONF_API_KEY: "bla", + }, + subentries_data=[ + ConfigSubentryData( + data=conversation_subentry_data, + subentry_id="ABCDEF", + subentry_type="conversation", + title="GPT-3.5 Turbo", + unique_id=None, + ) + ], + ) + + +@dataclass +class Model: + """Mock model data.""" + + id: str + name: str + + +@pytest.fixture +async def mock_openai_client() -> AsyncGenerator[AsyncMock]: + """Initialize integration.""" + with ( + patch("homeassistant.components.open_router.AsyncOpenAI") as mock_client, + patch( + "homeassistant.components.open_router.config_flow.AsyncOpenAI", + new=mock_client, + ), + ): + client = mock_client.return_value + client.with_options = MagicMock() + client.with_options.return_value.models = MagicMock() + client.with_options.return_value.models.list.return_value = ( + get_generator_from_data( + [ + Model(id="gpt-4", name="GPT-4"), + Model(id="gpt-3.5-turbo", name="GPT-3.5 Turbo"), + ], + ) + ) + client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + yield client + + +@pytest.fixture +async def mock_open_router_client() -> AsyncGenerator[AsyncMock]: + """Initialize integration.""" + with patch( + "homeassistant.components.open_router.config_flow.OpenRouterClient", + autospec=True, + ) as mock_client: + client = mock_client.return_value + yield client + + +@pytest.fixture(autouse=True) +async def setup_ha(hass: HomeAssistant) -> None: + """Set up Home Assistant.""" + assert await async_setup_component(hass, "homeassistant", {}) + + +async def get_generator_from_data[DataT](items: list[DataT]) -> AsyncGenerator[DataT]: + """Return async generator.""" + for item in items: + yield item diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr new file mode 100644 index 00000000000..d119c2f6aa5 --- /dev/null +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -0,0 +1,156 @@ +# serializer version: 1 +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'open_router', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GPT-3.5 Turbo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'open_router', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GPT-3.5 Turbo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_default_prompt + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'Hello, how can I help you?', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True] + list([ + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'I have successfully called the function', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/open_router/test_config_flow.py b/tests/components/open_router/test_config_flow.py new file mode 100644 index 00000000000..5e7a67d4a2b --- /dev/null +++ b/tests/components/open_router/test_config_flow.py @@ -0,0 +1,184 @@ +"""Test the OpenRouter config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from python_open_router import OpenRouterError + +from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "bla"} + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OpenRouterError("exception"), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle errors from the OpenRouter API.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_open_router_client.get_key_data.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_open_router_client.get_key_data.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting the flow if an entry already exists.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_create_conversation_agent( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent.""" + + mock_config_entry.add_to_hass(hass) + + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + } + + +async def test_create_conversation_agent_no_control( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent without control over the LLM API.""" + + mock_config_entry.add_to_hass(hass) + + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + } diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py new file mode 100644 index 00000000000..84742191efd --- /dev/null +++ b/tests/components/open_router/test_conversation.py @@ -0,0 +1,164 @@ +"""Tests for the OpenRouter integration.""" + +from unittest.mock import AsyncMock + +from freezegun import freeze_time +from openai.types import CompletionUsage +from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessage, + ChatCompletionMessageToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message_tool_call import Function +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er, intent + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_default_prompt( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that the default prompt works.""" + await setup_integration(hass, mock_config_entry) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.gpt_3_5_turbo", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + call = mock_openai_client.chat.completions.create.call_args_list[0][1] + assert call["model"] == "gpt-3.5-turbo" + assert call["extra_headers"] == { + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + "X-Title": "Home Assistant", + } + + +@pytest.mark.parametrize("enable_assist", [True]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, # noqa: F811 + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, +) -> None: + """Test function call from the assistant.""" + await setup_integration(hass, mock_config_entry) + + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + "call_call_2": "value2", + } + ) + + async def completion_result(*args, messages, **kwargs): + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + mock_openai_client.chat.completions.create = completion_result + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.gpt_3_5_turbo", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index 11dc978250a..c10c23df237 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -1,6 +1,12 @@ """Tests for the OpenAI Conversation integration.""" from openai.types.responses import ( + ResponseCodeInterpreterCallCodeDeltaEvent, + ResponseCodeInterpreterCallCodeDoneEvent, + ResponseCodeInterpreterCallCompletedEvent, + ResponseCodeInterpreterCallInProgressEvent, + ResponseCodeInterpreterCallInterpretingEvent, + ResponseCodeInterpreterToolCall, ResponseContentPartAddedEvent, ResponseContentPartDoneEvent, ResponseFunctionCallArgumentsDeltaEvent, @@ -239,3 +245,86 @@ def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEve type="response.output_item.done", ), ] + + +def create_code_interpreter_item( + id: str, code: str | list[str], output_index: int +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(code, str): + code = [code] + + container_id = "cntr_A" + events = [ + ResponseOutputItemAddedEvent( + item=ResponseCodeInterpreterToolCall( + id=id, + code="", + container_id=container_id, + outputs=None, + type="code_interpreter_call", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseCodeInterpreterCallInProgressEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.in_progress", + ), + ] + + events.extend( + ResponseCodeInterpreterCallCodeDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call_code.delta", + ) + for delta in code + ) + + code = "".join(code) + + events.extend( + [ + ResponseCodeInterpreterCallCodeDoneEvent( + item_id=id, + output_index=output_index, + code=code, + sequence_number=0, + type="response.code_interpreter_call_code.done", + ), + ResponseCodeInterpreterCallInterpretingEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.interpreting", + ), + ResponseCodeInterpreterCallCompletedEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseCodeInterpreterToolCall( + id=id, + code=code, + container_id=container_id, + outputs=None, + status="completed", + type="code_interpreter_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + ) + + return events diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 84c907a7c2e..b58e6c31f38 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -156,9 +156,10 @@ def mock_create_stream() -> Generator[AsyncMock]: ) yield ResponseInProgressEvent( response=response, - sequence_number=0, + sequence_number=1, type="response.in_progress", ) + sequence_number = 2 response.status = "completed" for value in events: @@ -173,6 +174,8 @@ def mock_create_stream() -> Generator[AsyncMock]: response.error = value break + value.sequence_number = sequence_number + sequence_number += 1 yield value if isinstance(value, ResponseErrorEvent): @@ -181,19 +184,19 @@ def mock_create_stream() -> Generator[AsyncMock]: if response.status == "incomplete": yield ResponseIncompleteEvent( response=response, - sequence_number=0, + sequence_number=sequence_number, type="response.incomplete", ) elif response.status == "failed": yield ResponseFailedEvent( response=response, - sequence_number=0, + sequence_number=sequence_number, type="response.failed", ) else: yield ResponseCompletedEvent( response=response, - sequence_number=0, + sequence_number=sequence_number, type="response.completed", ) diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 0ccbc39160a..6d8fb143f88 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -13,6 +13,7 @@ from homeassistant.components.openai_conversation.config_flow import ( ) from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_PROMPT, CONF_REASONING_EFFORT, @@ -311,6 +312,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ), { @@ -321,6 +323,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: 10000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ), ( # options for web search without user location @@ -343,6 +346,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), { @@ -355,6 +359,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), # Test that current options are showed as suggested values @@ -373,6 +378,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: True, }, ( { @@ -389,6 +395,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: True, }, ), { @@ -401,6 +408,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: True, }, ), ( # Case 2: reasoning model @@ -424,7 +432,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, }, - {CONF_REASONING_EFFORT: "high"}, + {CONF_REASONING_EFFORT: "high", CONF_CODE_INTERPRETER: False}, ), { CONF_RECOMMENDED: False, @@ -434,6 +442,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: False, }, ), # Test that old options are removed after reconfiguration @@ -445,6 +454,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "gpt-4o", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_CODE_INTERPRETER: True, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -476,6 +486,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ( { @@ -504,6 +515,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: True, }, ( { @@ -518,6 +530,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ), { @@ -528,6 +541,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ), ( # Case 4: reasoning to web search @@ -540,6 +554,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ( { @@ -556,6 +571,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "high", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), { @@ -568,6 +584,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "high", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), ], @@ -718,6 +735,7 @@ async def test_subentry_web_search_user_location( CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: False, } @@ -817,12 +835,24 @@ async def test_creating_ai_task_subentry_advanced( }, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Advanced AI Task" - assert result3.get("data") == { + assert result3.get("type") is FlowResultType.FORM + assert result3.get("step_id") == "model" + + # Configure model settings + result4 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_CODE_INTERPRETER: False, + }, + ) + + assert result4.get("type") is FlowResultType.CREATE_ENTRY + assert result4.get("title") == "Advanced AI Task" + assert result4.get("data") == { CONF_RECOMMENDED: False, CONF_CHAT_MODEL: "gpt-4o", CONF_MAX_TOKENS: 200, CONF_TEMPERATURE: 0.5, CONF_TOP_P: 0.9, + CONF_CODE_INTERPRETER: False, } diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 39cd129e1ba..dafcba7bfeb 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -16,6 +16,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.openai_conversation.const import ( + CONF_CODE_INTERPRETER, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_CONTEXT_SIZE, @@ -30,6 +31,7 @@ from homeassistant.helpers import intent from homeassistant.setup import async_setup_component from . import ( + create_code_interpreter_item, create_function_tool_call_item, create_message_item, create_reasoning_item, @@ -485,3 +487,49 @@ async def test_web_search( ] assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.speech["plain"]["speech"] == message, result.response.speech + + +async def test_code_interpreter( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test code_interpreter tool.""" + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={ + **subentry.data, + CONF_CODE_INTERPRETER: True, + }, + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + + message = "I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758." + mock_create_stream.return_value = [ + ( + *create_code_interpreter_item( + id="ci_A", + code=["import", " math", "\n", "math", ".sqrt", "(", "555", "55", ")"], + output_index=0, + ), + *create_message_item(id="msg_A", text=message, output_index=1), + ) + ] + + result = await conversation.async_converse( + hass, + "Please use the python tool to calculate square root of 55555", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai_conversation", + ) + + assert mock_create_stream.mock_calls[0][2]["tools"] == [ + {"type": "code_interpreter", "container": {"type": "auto"}} + ] + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == message, result.response.speech diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 5f6f3436699..77ec2377932 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -156,6 +156,9 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: ] } } + client.me.return_value.get_shareable_profile_link.return_value = { + "shareImageUrl": "https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493" + } yield client diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index ebf8d9e927f..0b7aa63fc03 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -71,6 +71,9 @@ 'PS5', 'PSVITA', ]), + 'shareable_profile_link': dict({ + 'shareImageUrl': 'https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493', + }), 'trophy_summary': dict({ 'account_id': '**REDACTED**', 'earned_trophies': dict({ diff --git a/tests/components/playstation_network/snapshots/test_media_player.ambr b/tests/components/playstation_network/snapshots/test_media_player.ambr index 69024c2326f..891509b351c 100644 --- a/tests/components/playstation_network/snapshots/test_media_player.ambr +++ b/tests/components/playstation_network/snapshots/test_media_player.ambr @@ -39,9 +39,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'receiver', - 'entity_picture_local': None, 'friendly_name': 'PlayStation Vita', - 'media_content_type': , 'supported_features': , }), 'context': , @@ -49,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'standby', + 'state': 'off', }) # --- # name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-entry] diff --git a/tests/components/playstation_network/test_image.py b/tests/components/playstation_network/test_image.py new file mode 100644 index 00000000000..0dc52646d9e --- /dev/null +++ b/tests/components/playstation_network/test_image.py @@ -0,0 +1,96 @@ +"""Test the PlayStation Network image platform.""" + +from collections.abc import Generator +from datetime import timedelta +from http import HTTPStatus +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +import respx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def image_only() -> Generator[None]: + """Enable only the image platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.IMAGE], + ): + yield + + +@respx.mock +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_image_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + mock_psnawpapi: MagicMock, +) -> None: + """Test image platform.""" + freezer.move_to("2025-06-16T00:00:00-00:00") + + respx.get( + "http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png" + ).respond(status_code=HTTPStatus.OK, content_type="image/png", content=b"Test") + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.testuser_avatar")) + assert state.state == "2025-06-16T00:00:00+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.testuser_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test" + assert resp.content_type == "image/png" + assert resp.content_length == 4 + + ava = "https://static-resource.np.community.playstation.net/avatar_m/WWS_E/E0011_m.png" + profile = mock_psnawpapi.user.return_value.profile.return_value + profile["avatars"] = [{"size": "xl", "url": ava}] + mock_psnawpapi.user.return_value.profile.return_value = profile + respx.get(ava).respond( + status_code=HTTPStatus.OK, content_type="image/png", content=b"Test2" + ) + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert (state := hass.states.get("image.testuser_avatar")) + assert state.state == "2025-06-16T00:00:30+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.testuser_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test2" + assert resp.content_type == "image/png" + assert resp.content_length == 5 diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 01e0c4458e4..520f8578c6e 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -449,3 +449,75 @@ async def test_fix_duplicate_device_ids( assert device_entry.identifiers == {(DOMAIN, MAC_ADDRESS_UNIQUE_ID)} assert device_entry.name_by_user == expected_device_name assert device_entry.disabled_by == expected_disabled_by + + +async def test_reload_migration_with_leading_zero_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration and reload of a device with a mac address with a leading zero.""" + mac_address = "01:02:03:04:05:06" + mac_address_unique_id = dr.format_mac(mac_address) + serial_number = "0" + + # Setup the config entry to be in a pre-migrated state + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=serial_number, + data={ + "host": "127.0.0.1", + "password": "password", + CONF_MAC: mac_address, + "serial_number": serial_number, + }, + ) + config_entry.add_to_hass(hass) + + # Create a device and entity with the old unique id format + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"{serial_number}-1")}, + ) + entity_entry = entity_registry.async_get_or_create( + "switch", + DOMAIN, + f"{serial_number}-1-zone1", + suggested_object_id="zone1", + config_entry=config_entry, + device_id=device_entry.id, + ) + + # Setup the integration, which will migrate the unique ids + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the device and entity were migrated to the new format + migrated_device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"{mac_address_unique_id}-1")} + ) + assert migrated_device_entry is not None + migrated_entity_entry = entity_registry.async_get(entity_entry.entity_id) + assert migrated_entity_entry is not None + assert migrated_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + + # Reload the integration + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the device and entity still have the correct identifiers and were not duplicated + reloaded_device_entry = device_registry.async_get(migrated_device_entry.id) + assert reloaded_device_entry is not None + assert reloaded_device_entry.identifiers == {(DOMAIN, f"{mac_address_unique_id}-1")} + reloaded_entity_entry = entity_registry.async_get(entity_entry.entity_id) + assert reloaded_entity_entry is not None + assert reloaded_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 1 + ) + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index e439d3dff93..10eefccace9 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -180,26 +180,6 @@ async def test_credential_error_three( assert (HOMEASSISTANT_DOMAIN, issue_id) in issue_registry.issues -async def test_entry_reloading( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_host: MagicMock, -) -> None: - """Test the entry is reloaded correctly when settings change.""" - reolink_host.is_nvr = False - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert reolink_host.logout.call_count == 0 - assert config_entry.title == "test_reolink_name" - - hass.config_entries.async_update_entry(config_entry, title="New Name") - await hass.async_block_till_done() - - assert reolink_host.logout.call_count == 1 - assert config_entry.title == "New Name" - - @pytest.mark.parametrize( ("attr", "value", "expected_models"), [ diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index fef2ff745cd..6fd6314c6bb 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -131,16 +131,11 @@ def schedule_setup( return _schedule_setup -async def test_invalid_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("invalid_config", [None, {"name with space": None}]) +async def test_invalid_config(hass: HomeAssistant, invalid_config) -> None: """Test invalid configs.""" - invalid_configs = [ - None, - {}, - {"name with space": None}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) @pytest.mark.parametrize( diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index a9456bd3cc6..f52f489aec3 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -7,10 +7,12 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from asyncsleepiq import ( BED_PRESETS, + CoreTemps, FootWarmingTemps, Side, SleepIQActuator, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQFoundation, SleepIQLight, @@ -29,6 +31,7 @@ from tests.common import MockConfigEntry BED_ID = "123456" BED_NAME = "Test Bed" BED_NAME_LOWER = BED_NAME.lower().replace(" ", "_") +CORE_CLIMATE_TIME = 240 SLEEPER_L_ID = "98765" SLEEPER_R_ID = "43219" SLEEPER_L_NAME = "SleeperL" @@ -91,6 +94,7 @@ def mock_bed() -> MagicMock: bed.foundation.lights = [light_1, light_2] bed.foundation.foot_warmers = [] + bed.foundation.core_climates = [] return bed @@ -127,6 +131,7 @@ def mock_asyncsleepiq_single_foundation( preset.options = BED_PRESETS mock_bed.foundation.foot_warmers = [] + mock_bed.foundation.core_climates = [] yield client @@ -185,6 +190,18 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock]: foot_warmer_r.timer = FOOT_WARM_TIME foot_warmer_r.temperature = FootWarmingTemps.OFF + core_climate_l = create_autospec(SleepIQCoreClimate) + core_climate_r = create_autospec(SleepIQCoreClimate) + mock_bed.foundation.core_climates = [core_climate_l, core_climate_r] + + core_climate_l.side = Side.LEFT + core_climate_l.timer = CORE_CLIMATE_TIME + core_climate_l.temperature = CoreTemps.COOLING_PULL_MED + + core_climate_r.side = Side.RIGHT + core_climate_r.timer = CORE_CLIMATE_TIME + core_climate_r.temperature = CoreTemps.OFF + yield client diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index f0739aabc9d..dd45cdc2400 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -198,3 +198,42 @@ async def test_foot_warmer_timer( await hass.async_block_till_done() assert mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[0].timer == 300 + + +async def test_core_climate_timer( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: + """Test the SleepIQ core climate number values for a bed with two sides.""" + entry = await setup_platform(hass, NUMBER_DOMAIN) + + state = hass.states.get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer" + ) + assert state.state == "240.0" + assert state.attributes.get(ATTR_ICON) == "mdi:timer" + assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MAX) == 600 + assert state.attributes.get(ATTR_STEP) == 30 + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate Timer" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_L_core_climate_timer" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer", + ATTR_VALUE: 420, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[0].timer == 420 diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index bbfb612e9cb..17d57eba7d3 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from asyncsleepiq import FootWarmingTemps +from asyncsleepiq import CoreTemps, FootWarmingTemps from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, @@ -21,6 +21,7 @@ from .conftest import ( BED_ID, BED_NAME, BED_NAME_LOWER, + CORE_CLIMATE_TIME, FOOT_WARM_TIME, PRESET_L_STATE, PRESET_R_STATE, @@ -204,3 +205,77 @@ async def test_foot_warmer( mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ 1 ].turn_on.assert_called_with(FootWarmingTemps.HIGH, FOOT_WARM_TIME) + + +async def test_core_climate( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test the SleepIQ select entity for core climate.""" + entry = await setup_platform(hass, SELECT_DOMAIN) + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate" + ) + assert state.state == "cooling_medium" + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_L_ID}_core_climate" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate", + ATTR_OPTION: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 0 + ].turn_off.assert_called_once() + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate" + ) + assert state.state == CoreTemps.OFF.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Core Climate" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_R_ID}_core_climate" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate", + ATTR_OPTION: "heating_high", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 1 + ].turn_on.assert_called_once() + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 1 + ].turn_on.assert_called_with(CoreTemps.HEATING_PUSH_HIGH, CORE_CLIMATE_TIME) diff --git a/tests/components/smhi/snapshots/test_sensor.ambr b/tests/components/smhi/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8fbdf229494 --- /dev/null +++ b/tests/components/smhi/snapshots/test_sensor.ambr @@ -0,0 +1,370 @@ +# serializer version: 1 +# name: test_sensor_setup[load_platforms0][sensor.test_frozen_precipitation-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.test_frozen_precipitation', + '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': 'Frozen precipitation', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frozen_precipitation', + 'unique_id': '59.32624, 17.84197-frozen_precipitation', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_frozen_precipitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Frozen precipitation', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_frozen_precipitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_high_cloud_coverage-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.test_high_cloud_coverage', + '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': 'High cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_cloud', + 'unique_id': '59.32624, 17.84197-high_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_high_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test High cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_high_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_low_cloud_coverage-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.test_low_cloud_coverage', + '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': 'Low cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_cloud', + 'unique_id': '59.32624, 17.84197-low_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_low_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Low cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_low_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_medium_cloud_coverage-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.test_medium_cloud_coverage', + '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': 'Medium cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'medium_cloud', + 'unique_id': '59.32624, 17.84197-medium_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_medium_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Medium cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_medium_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_precipitation_category-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_precipitation_category', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Precipitation category', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation_category', + 'unique_id': '59.32624, 17.84197-precipitation_category', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_precipitation_category-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'device_class': 'enum', + 'friendly_name': 'Test Precipitation category', + 'options': list([ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_precipitation_category', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_thunder_probability-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.test_thunder_probability', + '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': 'Thunder probability', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thunder', + 'unique_id': '59.32624, 17.84197-thunder', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_thunder_probability-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Thunder probability', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_thunder_probability', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_total_cloud_coverage-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.test_total_cloud_coverage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cloud', + 'unique_id': '59.32624, 17.84197-total_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_total_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Total cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_total_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/smhi/test_sensor.py b/tests/components/smhi/test_sensor.py new file mode 100644 index 00000000000..a56340af1b5 --- /dev/null +++ b/tests/components/smhi/test_sensor.py @@ -0,0 +1,26 @@ +"""Test for the smhi weather entity.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SENSOR]], +) +async def test_sensor_setup( + hass: HomeAssistant, + entity_registry: EntityRegistry, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test for successfully setting up the smhi sensors.""" + + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) diff --git a/tests/components/squeezebox/snapshots/test_init.ambr b/tests/components/squeezebox/snapshots/test_init.ambr new file mode 100644 index 00000000000..3fc65be834a --- /dev/null +++ b/tests/components/squeezebox/snapshots/test_init.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'squeezebox', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Ralph Irving & Adrian Smith', + 'model': 'SqueezeLite', + 'model_id': None, + 'name': 'Test Player', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '', + 'via_device_id': , + }) +# --- +# name: test_device_registry_server_merged + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'ff:ee:dd:cc:bb:aa', + ), + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'squeezebox', + '12345678-1234-1234-1234-123456789012', + ), + tuple( + 'squeezebox', + 'ff:ee:dd:cc:bb:aa', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'https://lyrion.org/ / Ralph Irving & Adrian Smith', + 'model': 'Lyrion Music Server/SqueezeLite', + 'model_id': 'LMS', + 'name': '1.2.3.4', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '', + 'via_device_id': , + }) +# --- diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index d86c839019c..183b5ca767f 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -1,82 +1,4 @@ # serializer version: 1 -# name: test_device_registry - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'squeezebox', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Ralph Irving & Adrian Smith', - 'model': 'SqueezeLite', - 'model_id': None, - 'name': 'Test Player', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '', - 'via_device_id': , - }) -# --- -# name: test_device_registry_server_merged - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - 'ff:ee:dd:cc:bb:aa', - ), - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'squeezebox', - '12345678-1234-1234-1234-123456789012', - ), - tuple( - 'squeezebox', - 'ff:ee:dd:cc:bb:aa', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'https://lyrion.org/ / Ralph Irving & Adrian Smith', - 'model': 'Lyrion Music Server/SqueezeLite', - 'model_id': 'LMS', - 'name': '1.2.3.4', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '', - 'via_device_id': , - }) -# --- # name: test_entity_registry[media_player.test_player-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/squeezebox/test_init.py b/tests/components/squeezebox/test_init.py index f70782b13da..5cb7e19abb5 100644 --- a/tests/components/squeezebox/test_init.py +++ b/tests/components/squeezebox/test_init.py @@ -1,10 +1,16 @@ """Test squeezebox initialization.""" from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.squeezebox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry + +from .conftest import TEST_MAC from tests.common import MockConfigEntry @@ -82,3 +88,27 @@ async def test_init_missing_uuid( mock_async_query.assert_called_once_with( "serverstatus", "-", "-", "prefs:libraryname" ) + + +async def test_device_registry( + hass: HomeAssistant, + device_registry: DeviceRegistry, + configured_player: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test squeezebox device registered in the device registry.""" + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[0])}) + assert reg_device is not None + assert reg_device == snapshot + + +async def test_device_registry_server_merged( + hass: HomeAssistant, + device_registry: DeviceRegistry, + configured_players: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test squeezebox device registered in the device registry.""" + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[2])}) + assert reg_device is not None + assert reg_device == snapshot diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index e1f480e33a0..440f682370b 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -68,7 +68,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.dt import utcnow @@ -82,30 +81,6 @@ from .conftest import ( from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_device_registry( - hass: HomeAssistant, - device_registry: DeviceRegistry, - configured_player: MagicMock, - snapshot: SnapshotAssertion, -) -> None: - """Test squeezebox device registered in the device registry.""" - reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[0])}) - assert reg_device is not None - assert reg_device == snapshot - - -async def test_device_registry_server_merged( - hass: HomeAssistant, - device_registry: DeviceRegistry, - configured_players: MagicMock, - snapshot: SnapshotAssertion, -) -> None: - """Test squeezebox device registered in the device registry.""" - reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[2])}) - assert reg_device is not None - assert reg_device == snapshot - - async def test_entity_registry( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -170,30 +145,21 @@ async def test_squeezebox_player_rediscovery( assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE -async def test_squeezebox_turn_on( - hass: HomeAssistant, configured_player: MagicMock +@pytest.mark.parametrize( + ("service", "state"), + [(SERVICE_TURN_ON, True), (SERVICE_TURN_OFF, False)], +) +async def test_squeezebox_turn_on_off( + hass: HomeAssistant, configured_player: MagicMock, service: str, state: bool ) -> None: """Test turn on service call.""" await hass.services.async_call( MEDIA_PLAYER_DOMAIN, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - configured_player.async_set_power.assert_called_once_with(True) - - -async def test_squeezebox_turn_off( - hass: HomeAssistant, configured_player: MagicMock -) -> None: - """Test turn off service call.""" - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "media_player.test_player"}, - blocking=True, - ) - configured_player.async_set_power.assert_called_once_with(False) + configured_player.async_set_power.assert_called_once_with(state) async def test_squeezebox_state( @@ -535,7 +501,10 @@ async def test_squeezebox_play_media_with_announce_volume_invalid( hass: HomeAssistant, configured_player: MagicMock, announce_volume: str | int ) -> None: """Test play service call with announce and volume zero.""" - with pytest.raises(ServiceValidationError): + with pytest.raises( + ServiceValidationError, + match="announce_volume must be a number greater than 0 and less than or equal to 1", + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index c11045a2eb2..2312daa8c52 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -10,7 +10,7 @@ from homeassistant.components import statistics from homeassistant.components.statistics import DOMAIN from homeassistant.components.statistics.config_flow import StatisticsConfigFlowHandler from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -85,6 +85,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -173,7 +174,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( statistics_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(statistics_config_entry.entry_id) @@ -188,9 +189,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( statistics_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -201,6 +200,53 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the statistics config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_statistics") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the statistics config entry is removed + assert statistics_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the statistics config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -217,7 +263,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -234,7 +280,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_statistics") + + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -261,7 +310,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -276,7 +325,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is removed from the device + # Check that the entity is no longer linked to the source device + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id is None + + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -309,7 +362,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert statistics_config_entry.entry_id not in sensor_device_2.config_entries @@ -326,11 +379,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is moved to the other device + # Check that the entity is linked to the other device + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_device_2.id + + # Check that the history_stats config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert statistics_config_entry.entry_id in sensor_device_2.config_entries + assert statistics_config_entry.entry_id not in sensor_device_2.config_entries # Check that the statistics config entry is not removed assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -355,7 +412,7 @@ async def test_async_handle_source_entity_new_entity_id( assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -373,12 +430,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the statistics config entry is updated with the new entity ID assert statistics_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries # Check that the statistics config entry is not removed assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes statistics config entry from device.""" + + statistics_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": sensor_entity_entry.entity_id, + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 20.0, + }, + title="My statistics", + version=1, + minor_version=1, + ) + statistics_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=statistics_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + assert statistics_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + assert statistics_config_entry.version == 1 + assert statistics_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": "sensor.test", + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 20.0, + }, + title="My statistics", + version=2, + 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 config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 21df0146ef5..e882909878a 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -6,7 +6,7 @@ from asyncio import Event as AsyncioEvent from collections.abc import Sequence from datetime import datetime, timedelta import statistics -from threading import Event +from threading import Event as ThreadingEvent from typing import Any from unittest.mock import patch @@ -42,8 +42,9 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -54,6 +55,9 @@ VALUES_BINARY = ["on", "off", "on", "off", "on", "off", "on", "off", "on"] VALUES_NUMERIC = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6] VALUES_NUMERIC_LINEAR = [1, 2, 3, 4, 5, 6, 7, 8, 9] +A1 = {"attr": "value1"} +A2 = {"attr": "value2"} + async def test_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry @@ -249,7 +253,22 @@ async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: assert "age_coverage_ratio" not in state.attributes -async def test_sensor_state_reported(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("force_update", [True, False]) +@pytest.mark.parametrize( + ("values", "attributes"), + [ + # Fires last reported events + ([18, 1, 1, 1, 1, 1, 1, 1, 9], [A1, A1, A1, A1, A1, A1, A1, A1, A1]), + # Fires state change events + ([18, 1, 1, 1, 1, 1, 1, 1, 9], [A1, A2, A1, A2, A1, A2, A1, A2, A1]), + ], +) +async def test_sensor_state_updated_reported( + hass: HomeAssistant, + values: list[float], + attributes: list[dict[str, Any]], + force_update: bool, +) -> None: """Test the behavior of the sensor with a sequence of identical values. Forced updates no longer make a difference, since the statistics are now reacting not @@ -258,7 +277,6 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: This fixes problems with time based averages and some other functions that behave differently when repeating values are reported. """ - repeating_values = [18, 0, 0, 0, 0, 0, 0, 0, 9] assert await async_setup_component( hass, "sensor", @@ -267,14 +285,7 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: { "platform": "statistics", "name": "test_normal", - "entity_id": "sensor.test_monitored_normal", - "state_characteristic": "mean", - "sampling_size": 20, - }, - { - "platform": "statistics", - "name": "test_force", - "entity_id": "sensor.test_monitored_force", + "entity_id": "sensor.test_monitored", "state_characteristic": "mean", "sampling_size": 20, }, @@ -283,27 +294,19 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - for value in repeating_values: + for value, attribute in zip(values, attributes, strict=True): hass.states.async_set( - "sensor.test_monitored_normal", + "sensor.test_monitored", str(value), - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, - ) - hass.states.async_set( - "sensor.test_monitored_force", - str(value), - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, - force_update=True, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS} | attribute, + force_update=force_update, ) await hass.async_block_till_done() - state_normal = hass.states.get("sensor.test_normal") - state_force = hass.states.get("sensor.test_force") - assert state_normal and state_force - assert state_normal.state == str(round(sum(repeating_values) / 9, 2)) - assert state_force.state == str(round(sum(repeating_values) / 9, 2)) - assert state_normal.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) - assert state_force.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + state = hass.states.get("sensor.test_normal") + assert state + assert state.state == str(round(sum(values) / 9, 2)) + assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) async def test_sampling_boundaries_given(hass: HomeAssistant) -> None: @@ -1739,7 +1742,7 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) # some synchronisation is needed to prevent that loading from the database finishes too soon # we want this to take long enough to be able to try to add a value BEFORE loading is done state_changes_during_period_called_evt = AsyncioEvent() - state_changes_during_period_stall_evt = Event() + state_changes_during_period_stall_evt = ThreadingEvent() real_state_changes_during_period = history.state_changes_during_period def mock_state_changes_during_period(*args, **kwargs): @@ -1785,12 +1788,49 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) assert float(hass.states.get("sensor.test").state) == pytest.approx(4.5) -async def test_average_linear_unevenly_timed(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("force_update", [True, False]) +@pytest.mark.parametrize( + ("values_attributes_and_times", "expected_states"), + [ + ( + # Fires last reported events + [(5.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (5.0, A1, 1)], + ["unavailable", "5.0", "7.5", "8.33", "8.75", "8.33"], + ), + ( # Fires state change events + [(5.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (5.0, A1, 1)], + ["unavailable", "5.0", "7.5", "8.33", "8.75", "8.33"], + ), + ( + # Fires last reported events + [(10.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (10.0, A1, 1)], + ["unavailable", "10.0", "10.0", "10.0", "10.0", "10.0"], + ), + ( # Fires state change events + [(10.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (10.0, A1, 1)], + ["unavailable", "10.0", "10.0", "10.0", "10.0", "10.0"], + ), + ], +) +async def test_average_linear_unevenly_timed( + hass: HomeAssistant, + force_update: bool, + values_attributes_and_times: list[tuple[float, dict[str, Any], float]], + expected_states: list[str], +) -> None: """Test the average_linear state characteristic with unevenly distributed values. This also implicitly tests the correct timing of repeating values. """ - values_and_times = [[5.0, 2], [10.0, 1], [10.0, 1], [10.0, 2], [5.0, 1]] + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event( + hass, "sensor.test_sensor_average_linear", _capture_event + ) current_time = dt_util.utcnow() @@ -1814,23 +1854,20 @@ async def test_average_linear_unevenly_timed(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - for value_and_time in values_and_times: + for value, extra_attributes, time in values_attributes_and_times: hass.states.async_set( "sensor.test_monitored", - str(value_and_time[0]), - {ATTR_UNIT_OF_MEASUREMENT: DEGREE}, + str(value), + {ATTR_UNIT_OF_MEASUREMENT: DEGREE} | extra_attributes, + force_update=force_update, ) - current_time += timedelta(seconds=value_and_time[1]) + current_time += timedelta(seconds=time) freezer.move_to(current_time) await hass.async_block_till_done() - state = hass.states.get("sensor.test_sensor_average_linear") - assert state is not None - assert state.state == "8.33", ( - "value mismatch for characteristic 'sensor/average_linear' - " - f"assert {state.state} == 8.33" - ) + await hass.async_block_till_done() + assert [event.data["new_state"].state for event in events] == expected_states async def test_sensor_unit_gets_removed(hass: HomeAssistant) -> None: diff --git a/tests/components/tado/fixtures/heating_circuits.json b/tests/components/tado/fixtures/heating_circuits.json new file mode 100644 index 00000000000..723ceb76f95 --- /dev/null +++ b/tests/components/tado/fixtures/heating_circuits.json @@ -0,0 +1,7 @@ +[ + { + "number": 1, + "driverSerialNo": "RU1234567890", + "driverShortSerialNo": "RU1234567890" + } +] diff --git a/tests/components/tado/fixtures/zone_control.json b/tests/components/tado/fixtures/zone_control.json new file mode 100644 index 00000000000..584fe9f3c92 --- /dev/null +++ b/tests/components/tado/fixtures/zone_control.json @@ -0,0 +1,80 @@ +{ + "type": "HEATING", + "earlyStartEnabled": false, + "heatingCircuit": 1, + "duties": { + "type": "HEATING", + "leader": { + "deviceType": "RU01", + "serialNo": "RU1234567890", + "shortSerialNo": "RU1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:53:40.710Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "batteryState": "NORMAL" + }, + "drivers": [ + { + "deviceType": "VA01", + "serialNo": "VA1234567890", + "shortSerialNo": "VA1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:54:15.166Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "mountingState": { + "value": "CALIBRATED", + "timestamp": "2025-06-09T23:25:12.678Z" + }, + "mountingStateWithError": "CALIBRATED", + "batteryState": "LOW", + "childLockEnabled": false + } + ], + "uis": [ + { + "deviceType": "RU01", + "serialNo": "RU1234567890", + "shortSerialNo": "RU1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:53:40.710Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "batteryState": "NORMAL" + }, + { + "deviceType": "VA01", + "serialNo": "VA1234567890", + "shortSerialNo": "VA1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:54:15.166Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "mountingState": { + "value": "CALIBRATED", + "timestamp": "2025-06-09T23:25:12.678Z" + }, + "mountingStateWithError": "CALIBRATED", + "batteryState": "LOW", + "childLockEnabled": false + } + ] + } +} diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr index eefb818a88c..34d26c222fa 100644 --- a/tests/components/tado/snapshots/test_diagnostics.ambr +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -62,6 +62,13 @@ 'presence': 'HOME', 'presenceLocked': False, }), + 'heating_circuits': dict({ + 'RU1234567890': dict({ + 'driverSerialNo': 'RU1234567890', + 'driverShortSerialNo': 'RU1234567890', + 'number': 1, + }), + }), 'weather': dict({ 'outsideTemperature': dict({ 'celsius': 7.46, @@ -110,6 +117,560 @@ 'repr': "TadoZone(zone_id=6, current_temp=24.3, connection=None, current_temp_timestamp='2024-06-28T22: 23: 15.679Z', current_humidity=70.9, current_humidity_timestamp='2024-06-28T22: 23: 15.679Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level='LEVEL3', current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='ON', current_horizontal_swing_mode='ON', target_temp=25.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2022-07-13T18: 06: 58.183Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", }), }), + 'zone_control': dict({ + '1': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '2': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '3': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '4': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '5': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '6': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + }), }), 'mobile_devices': dict({ 'mobile_device': dict({ diff --git a/tests/components/tado/test_select.py b/tests/components/tado/test_select.py new file mode 100644 index 00000000000..e57b7510d1b --- /dev/null +++ b/tests/components/tado/test_select.py @@ -0,0 +1,91 @@ +"""The select tests for the tado platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +HEATING_CIRCUIT_SELECT_ENTITY = "select.baseboard_heater_heating_circuit" +NO_HEATING_CIRCUIT = "no_heating_circuit" +HEATING_CIRCUIT_OPTION = "RU1234567890" +ZONE_ID = 1 +HEATING_CIRCUIT_ID = 1 + + +async def test_heating_circuit_select(hass: HomeAssistant) -> None: + """Test creation of heating circuit select entity.""" + + await async_init_integration(hass) + state = hass.states.get(HEATING_CIRCUIT_SELECT_ENTITY) + assert state is not None + assert state.state == HEATING_CIRCUIT_OPTION + assert NO_HEATING_CIRCUIT in state.attributes["options"] + assert HEATING_CIRCUIT_OPTION in state.attributes["options"] + + +@pytest.mark.parametrize( + ("option", "expected_circuit_id"), + [(HEATING_CIRCUIT_OPTION, HEATING_CIRCUIT_ID), (NO_HEATING_CIRCUIT, None)], +) +async def test_heating_circuit_select_action( + hass: HomeAssistant, option, expected_circuit_id +) -> None: + """Test selecting heating circuit option.""" + + await async_init_integration(hass) + + # Test selecting a specific heating circuit + with ( + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_heating_circuit" + ) as mock_set_zone_heating_circuit, + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_control" + ) as mock_get_zone_control, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: HEATING_CIRCUIT_SELECT_ENTITY, + ATTR_OPTION: option, + }, + blocking=True, + ) + + mock_set_zone_heating_circuit.assert_called_with(ZONE_ID, expected_circuit_id) + assert mock_get_zone_control.called + + +@pytest.mark.usefixtures("caplog") +async def test_heating_circuit_not_found( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test when a heating circuit with a specific number is not found.""" + circuit_not_matching_zone_control = 999 + heating_circuits = [ + { + "number": circuit_not_matching_zone_control, + "driverSerialNo": "RU1234567890", + "driverShortSerialNo": "RU1234567890", + } + ] + + with patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_heating_circuits", + return_value=heating_circuits, + ): + await async_init_integration(hass) + + state = hass.states.get(HEATING_CIRCUIT_SELECT_ENTITY) + assert state.state == NO_HEATING_CIRCUIT + + assert "Heating circuit with number 1 not found for zone" in caplog.text diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 8ee7209acb2..5ef0ab5dbf2 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -20,8 +20,10 @@ async def async_init_integration( me_fixture = "me.json" weather_fixture = "weather.json" home_fixture = "home.json" + home_heating_circuits_fixture = "heating_circuits.json" home_state_fixture = "home_state.json" zones_fixture = "zones.json" + zone_control_fixture = "zone_control.json" zone_states_fixture = "zone_states.json" # WR1 Device @@ -70,6 +72,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/", text=await async_load_fixture(hass, home_fixture, DOMAIN), ) + m.get( + "https://my.tado.com/api/v2/homes/1/heatingCircuits", + text=await async_load_fixture(hass, home_heating_circuits_fixture, DOMAIN), + ) m.get( "https://my.tado.com/api/v2/homes/1/weather", text=await async_load_fixture(hass, weather_fixture, DOMAIN), @@ -178,6 +184,12 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/state", text=await async_load_fixture(hass, zone_1_state_fixture, DOMAIN), ) + zone_ids = [1, 2, 3, 4, 5, 6] + for zone_id in zone_ids: + m.get( + f"https://my.tado.com/api/v2/homes/1/zones/{zone_id}/control", + text=await async_load_fixture(hass, zone_control_fixture, DOMAIN), + ) m.post( "https://login.tado.com/oauth2/token", text=await async_load_fixture(hass, token_fixture, DOMAIN), diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr index bdda5b44e94..215a10a4f40 100644 --- a/tests/components/template/snapshots/test_weather.ambr +++ b/tests/components/template/snapshots/test_weather.ambr @@ -46,6 +46,7 @@ 'last_ozone': None, 'last_pressure': None, 'last_temperature': '15.0', + 'last_uv_index': None, 'last_visibility': None, 'last_wind_bearing': None, 'last_wind_gust_speed': None, diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 1984b4ea2af..06d678edcab 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -23,9 +23,10 @@ from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache +from tests.conftest import WebSocketGenerator TEST_OBJECT_ID = "test_template_panel" TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}" @@ -915,3 +916,19 @@ async def test_device_id( template_entity = entity_registry.async_get("alarm_control_panel.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + ALARM_DOMAIN, + {"name": "My template", "state": "{{ 'disarmed' }}"}, + ) + + assert state["state"] == AlarmControlPanelState.DISARMED diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 67a85839982..8e98d8c94a7 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -9,9 +9,5 @@ from homeassistant.core import HomeAssistant async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: """Test abstract template entity raises not implemented error.""" - entity = abstract_entity.AbstractTemplateEntity(None) - with pytest.raises(NotImplementedError): - _ = entity.referenced_blueprint - - with pytest.raises(NotImplementedError): - entity._render_script_variables() + with pytest.raises(TypeError): + _ = abstract_entity.AbstractTemplateEntity(hass, {}) diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index cab940d4c66..0d593da9fba 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -9,7 +9,7 @@ from homeassistant import config from homeassistant.components.template import DOMAIN from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -369,6 +369,7 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None: async def test_change_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, config_entry_options: dict[str, str], config_user_input: dict[str, str], ) -> None: @@ -379,6 +380,19 @@ async def test_change_device( changed in the integration options. """ + def check_template_entities( + template_entity_id: str, + device_id: str | None = None, + ) -> None: + """Check that the template entity is linked to the correct device.""" + template_entity_ids: list[str] = [] + for template_entity in entity_registry.entities.get_entries_for_config_entry_id( + template_config_entry.entry_id + ): + template_entity_ids.append(template_entity.entity_id) + assert template_entity.device_id == device_id + assert template_entity_ids == [template_entity_id] + # Configure devices registry entry_device1 = MockConfigEntry() entry_device1.add_to_hass(hass) @@ -413,9 +427,14 @@ async def test_change_device( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - # Confirm that the config entry has been added to the device 1 registry (current) - current_device = device_registry.async_get(device_id=device_id1) - assert template_config_entry.entry_id in current_device.config_entries + template_entity_id = f"{config_entry_options['template_type']}.my_template" + + # Confirm that the template config entry has not been added to either device + # and that the entities are linked to device 1 + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, device_id1) # Change config options to use device 2 and reload the integration result = await hass.config_entries.options.async_init( @@ -427,13 +446,12 @@ async def test_change_device( ) await hass.async_block_till_done() - # Confirm that the config entry has been removed from the device 1 registry - previous_device = device_registry.async_get(device_id=device_id1) - assert template_config_entry.entry_id not in previous_device.config_entries - - # Confirm that the config entry has been added to the device 2 registry (current) - current_device = device_registry.async_get(device_id=device_id2) - assert template_config_entry.entry_id in current_device.config_entries + # Confirm that the template config entry has not been added to either device + # and that the entities are linked to device 2 + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, device_id2) # Change the config options to remove the device and reload the integration result = await hass.config_entries.options.async_init( @@ -445,9 +463,12 @@ async def test_change_device( ) await hass.async_block_till_done() - # Confirm that the config entry has been removed from the device 2 registry - previous_device = device_registry.async_get(device_id=device_id2) - assert template_config_entry.entry_id not in previous_device.config_entries + # Confirm that the template config entry has not been added to either device + # and that the entities are not linked to any device + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, None) # Confirm that there is no device with the helper config entry assert ( diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 6971d41750d..f613fa865a6 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -35,9 +35,10 @@ from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, async_capture_events +from tests.conftest import WebSocketGenerator _TEST_OBJECT_ID = "template_select" _TEST_SELECT = f"select.{_TEST_OBJECT_ID}" @@ -645,3 +646,19 @@ async def test_availability(hass: HomeAssistant) -> None: state = hass.states.get(_TEST_SELECT) assert state.state == "yes" + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + select.DOMAIN, + {"name": "My template", **TEST_OPTIONS}, + ) + + assert state["state"] == "test" diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index e89e98601d6..9aba8511192 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1141,7 +1141,7 @@ async def test_duplicate_templates(hass: HomeAssistant) -> None: "unique_id": "listening-test-event", "trigger": {"platform": "event", "event_type": "test_event"}, "sensors": { - "hello": { + "hello_name": { "friendly_name": "Hello Name", "unique_id": "hello_name-id", "device_class": "battery", @@ -1360,7 +1360,7 @@ async def test_trigger_conditional_entity_invalid_condition( { "trigger": {"platform": "event", "event_type": "test_event"}, "sensors": { - "hello": { + "hello_name": { "friendly_name": "Hello Name", "value_template": "{{ trigger.event.data.beer }}", "entity_picture_template": "{{ '/local/dogs.png' }}", diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index d66fc2710c9..7fe3870ae1e 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,12 +9,8 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(None) + entity = template_entity.TemplateEntity(hass, {}, "something_unique") - with pytest.raises(ValueError, match="^hass cannot be None"): - entity.add_template_attribute("_hello", template.Template("Hello")) - - entity.hass = object() with pytest.raises(ValueError, match="^template.hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello", None)) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 443b0aa6e77..6e2a2ab2f6b 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -15,6 +15,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_OZONE, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, @@ -608,6 +609,7 @@ SAVED_EXTRA_DATA = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -623,6 +625,7 @@ SAVED_EXTRA_DATA_WITH_FUTURE_KEY = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -790,6 +793,7 @@ async def test_trigger_action(hass: HomeAssistant) -> None: "wind_speed_template": "{{ my_variable + 1 }}", "wind_bearing_template": "{{ my_variable + 1 }}", "ozone_template": "{{ my_variable + 1 }}", + "uv_index_template": "{{ my_variable + 1 }}", "visibility_template": "{{ my_variable + 1 }}", "pressure_template": "{{ my_variable + 1 }}", "wind_gust_speed_template": "{{ my_variable + 1 }}", @@ -864,6 +868,7 @@ async def test_trigger_weather_services( assert state.attributes["wind_speed"] == 3.0 assert state.attributes["wind_bearing"] == 3.0 assert state.attributes["ozone"] == 3.0 + assert state.attributes["uv_index"] == 3.0 assert state.attributes["visibility"] == 3.0 assert state.attributes["pressure"] == 3.0 assert state.attributes["wind_gust_speed"] == 3.0 @@ -962,6 +967,7 @@ SAVED_EXTRA_DATA_MISSING_KEY = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -1041,6 +1047,7 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: "wind_speed_template": "{{ states('sensor.windspeed') }}", "wind_bearing_template": "{{ states('sensor.windbearing') }}", "ozone_template": "{{ states('sensor.ozone') }}", + "uv_index_template": "{{ states('sensor.uv_index') }}", "visibility_template": "{{ states('sensor.visibility') }}", "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", @@ -1063,6 +1070,7 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.uv_index", ATTR_WEATHER_UV_INDEX, 3.7), ("sensor.visibility", ATTR_WEATHER_VISIBILITY, 4.6), ("sensor.wind_gust_speed", ATTR_WEATHER_WIND_GUST_SPEED, 30), ("sensor.cloud_coverage", ATTR_WEATHER_CLOUD_COVERAGE, 75), diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 0152543e512..ffcc74d5587 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -14,6 +14,7 @@ from .const import ( ENERGY_HISTORY, LIVE_STATUS, METADATA, + METADATA_LEGACY, PRODUCTS, SITE_INFO, VEHICLE_DATA, @@ -53,9 +54,9 @@ def mock_vehicle_data() -> Generator[AsyncMock]: def mock_legacy(): """Mock Tesla Fleet Api products method.""" with patch( - "tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True - ) as mock_pre2021: - yield mock_pre2021 + "tesla_fleet_api.teslemetry.Teslemetry.metadata", return_value=METADATA_LEGACY + ) as mock_products: + yield mock_products @pytest.fixture(autouse=True) @@ -119,8 +120,17 @@ def mock_energy_history(): @pytest.fixture(autouse=True) -def mock_add_listener(): +def mock_stream_listen(): """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStream.listen", + ) as mock_stream_listen: + yield mock_stream_listen + + +@pytest.fixture(autouse=True) +def mock_add_listener(): + """Mock Teslemetry Stream add listener method.""" with patch( "teslemetry_stream.TeslemetryStream.async_add_listener", ) as mock_add_listener: diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 3bfa452e38d..7b671bbeaaa 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -37,6 +37,32 @@ COMMAND_ERRORS = (COMMAND_REASON, COMMAND_NOREASON, COMMAND_ERROR, COMMAND_NOERR RESPONSE_OK = {"response": {}, "error": None} METADATA = { + "uid": "abc-123", + "region": "NA", + "scopes": [ + "openid", + "offline_access", + "user_data", + "vehicle_device_data", + "vehicle_cmds", + "vehicle_charging_cmds", + "vehicle_location", + "energy_device_data", + "energy_cmds", + ], + "vehicles": { + "LRW3F7EK4NC700000": { + "proxy": True, + "access": True, + "polling": False, + "firmware": "2026.0.0", + "discounted": False, + "fleet_telemetry": "1.0.2", + "name": "Home Assistant", + } + }, +} +METADATA_LEGACY = { "uid": "abc-123", "region": "NA", "scopes": [ @@ -56,6 +82,9 @@ METADATA = { "access": True, "polling": True, "firmware": "2026.0.0", + "discounted": True, + "fleet_telemetry": "unknown", + "name": "Home Assistant", } }, } @@ -68,7 +97,10 @@ METADATA_NOSCOPE = { "proxy": False, "access": True, "polling": True, - "firmware": "2024.44.25", + "firmware": "2026.0.0", + "discounted": True, + "fleet_telemetry": "unknown", + "name": "Home Assistant", } }, } diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 06ec0a60434..2b920a0cfdc 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -240,102 +240,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', - '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': 'Automatic blind spot camera', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'automatic_blind_spot_camera', - 'unique_id': 'LRW3F7EK4NC700000-automatic_blind_spot_camera', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic blind spot camera', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Automatic emergency braking off', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'automatic_emergency_braking_off', - 'unique_id': 'LRW3F7EK4NC700000-automatic_emergency_braking_off', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic emergency braking off', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_battery_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -382,151 +286,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', - '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': 'Blind spot collision warning chime', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'blind_spot_collision_warning_chime', - 'unique_id': 'LRW3F7EK4NC700000-blind_spot_collision_warning_chime', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Blind spot collision warning chime', - }), - 'context': , - 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_bms_full_charge-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_bms_full_charge', - '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': 'BMS full charge', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'bms_full_charge_complete', - 'unique_id': 'LRW3F7EK4NC700000-bms_full_charge_complete', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_bms_full_charge-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test BMS full charge', - }), - 'context': , - 'entity_id': 'binary_sensor.test_bms_full_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_brake_pedal-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_brake_pedal', - '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': 'Brake pedal', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'brake_pedal', - 'unique_id': 'LRW3F7EK4NC700000-brake_pedal', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_brake_pedal-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Brake pedal', - }), - 'context': , - 'entity_id': 'binary_sensor.test_brake_pedal', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-entry] @@ -578,55 +338,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_cellular-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_cellular', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cellular', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cellular', - 'unique_id': 'LRW3F7EK4NC700000-cellular', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_cellular-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Cellular', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cellular', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_charge_cable-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -673,103 +384,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_enable_request-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_charge_enable_request', - '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': 'Charge enable request', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charge_enable_request', - 'unique_id': 'LRW3F7EK4NC700000-charge_enable_request', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_enable_request-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge enable request', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_enable_request', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Charge port cold weather mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charge_port_cold_weather_mode', - 'unique_id': 'LRW3F7EK4NC700000-charge_port_cold_weather_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge port cold weather mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-entry] @@ -817,7 +432,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_binary_sensor[binary_sensor.test_dashcam-entry] @@ -869,390 +484,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_dc_to_dc_converter', - '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': 'DC to DC converter', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'dc_dc_enable', - 'unique_id': 'LRW3F7EK4NC700000-dc_dc_enable', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test DC to DC converter', - }), - 'context': , - 'entity_id': 'binary_sensor.test_dc_to_dc_converter', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', - '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': 'Defrost for preconditioning', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'defrost_for_preconditioning', - 'unique_id': 'LRW3F7EK4NC700000-defrost_for_preconditioning', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Defrost for preconditioning', - }), - 'context': , - 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_drive_rail-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_drive_rail', - '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': 'Drive rail', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'drive_rail', - 'unique_id': 'LRW3F7EK4NC700000-drive_rail', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_drive_rail-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Drive rail', - }), - 'context': , - 'entity_id': 'binary_sensor.test_drive_rail', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_driver_seat_belt', - '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': 'Driver seat belt', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'driver_seat_belt', - 'unique_id': 'LRW3F7EK4NC700000-driver_seat_belt', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_driver_seat_occupied', - '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': 'Driver seat occupied', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'driver_seat_occupied', - 'unique_id': 'LRW3F7EK4NC700000-driver_seat_occupied', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat occupied', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_occupied', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', - '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': 'Emergency lane departure avoidance', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'emergency_lane_departure_avoidance', - 'unique_id': 'LRW3F7EK4NC700000-emergency_lane_departure_avoidance', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Emergency lane departure avoidance', - }), - 'context': , - 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_european_vehicle-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_european_vehicle', - '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': 'European vehicle', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'europe_vehicle', - 'unique_id': 'LRW3F7EK4NC700000-europe_vehicle', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_european_vehicle-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test European vehicle', - }), - 'context': , - 'entity_id': 'binary_sensor.test_european_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_fast_charger_present-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_fast_charger_present', - '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': 'Fast charger present', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'fast_charger_present', - 'unique_id': 'LRW3F7EK4NC700000-fast_charger_present', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_fast_charger_present-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Fast charger present', - }), - 'context': , - 'entity_id': 'binary_sensor.test_fast_charger_present', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_front_driver_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1299,7 +530,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_driver_window-entry] @@ -1348,7 +579,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_door-entry] @@ -1397,7 +628,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_window-entry] @@ -1446,633 +677,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_gps_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_gps_state', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'GPS state', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'gps_state', - 'unique_id': 'LRW3F7EK4NC700000-gps_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_gps_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test GPS state', - }), - 'context': , - 'entity_id': 'binary_sensor.test_gps_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_guest_mode_enabled', - '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': 'Guest mode enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'guest_mode_enabled', - 'unique_id': 'LRW3F7EK4NC700000-guest_mode_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Guest mode enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_guest_mode_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hazard_lights-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_hazard_lights', - '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': 'Hazard lights', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'lights_hazards_active', - 'unique_id': 'LRW3F7EK4NC700000-lights_hazards_active', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hazard_lights-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Hazard lights', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hazard_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_beams-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_high_beams', - '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': 'High beams', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'lights_high_beams', - 'unique_id': 'LRW3F7EK4NC700000-lights_high_beams', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_beams-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test High beams', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_beams', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'High voltage interlock loop fault', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hvil', - 'unique_id': 'LRW3F7EK4NC700000-hvil', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test High voltage interlock loop fault', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_homelink_nearby-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_homelink_nearby', - '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': 'Homelink nearby', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'homelink_nearby', - 'unique_id': 'LRW3F7EK4NC700000-homelink_nearby', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_homelink_nearby-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Homelink nearby', - }), - 'context': , - 'entity_id': 'binary_sensor.test_homelink_nearby', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_hvac_auto_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'HVAC auto mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_auto_mode', - 'unique_id': 'LRW3F7EK4NC700000-hvac_auto_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test HVAC auto mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hvac_auto_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_favorite-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_located_at_favorite', - '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': 'Located at favorite', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_favorite', - 'unique_id': 'LRW3F7EK4NC700000-located_at_favorite', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_favorite-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at favorite', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_favorite', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_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': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_located_at_home', - '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': 'Located at home', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_home', - 'unique_id': 'LRW3F7EK4NC700000-located_at_home', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_home-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at home', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_work-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_located_at_work', - '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': 'Located at work', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_work', - 'unique_id': 'LRW3F7EK4NC700000-located_at_work', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_work-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at work', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_work', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_offroad_lightbar', - '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': 'Offroad lightbar', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'offroad_lightbar_present', - 'unique_id': 'LRW3F7EK4NC700000-offroad_lightbar_present', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Offroad lightbar', - }), - 'context': , - 'entity_id': 'binary_sensor.test_offroad_lightbar', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_passenger_seat_belt', - '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': 'Passenger seat belt', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_seat_belt', - 'unique_id': 'LRW3F7EK4NC700000-passenger_seat_belt', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Passenger seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_passenger_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', - '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': 'PIN to Drive enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'pin_to_drive_enabled', - 'unique_id': 'LRW3F7EK4NC700000-pin_to_drive_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test PIN to Drive enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_preconditioning-entry] @@ -2168,55 +773,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_rear_display_hvac', - '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': 'Rear display HVAC', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'rear_display_hvac_enabled', - 'unique_id': 'LRW3F7EK4NC700000-rear_display_hvac_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Rear display HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_display_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] @@ -2265,7 +822,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_window-entry] @@ -2314,7 +871,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_door-entry] @@ -2363,7 +920,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_window-entry] @@ -2412,103 +969,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_remote_start-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_remote_start', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Remote start', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'remote_start_enabled', - 'unique_id': 'LRW3F7EK4NC700000-remote_start_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_remote_start-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Remote start', - }), - 'context': , - 'entity_id': 'binary_sensor.test_remote_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_right_hand_drive-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_right_hand_drive', - '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': 'Right hand drive', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'right_hand_drive', - 'unique_id': 'LRW3F7EK4NC700000-right_hand_drive', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_right_hand_drive-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Right hand drive', - }), - 'context': , - 'entity_id': 'binary_sensor.test_right_hand_drive', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-entry] @@ -2556,151 +1017,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_seat_vent_enabled', - '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': 'Seat vent enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'seat_vent_enabled', - 'unique_id': 'LRW3F7EK4NC700000-seat_vent_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat vent enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_seat_vent_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_service_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_service_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Service mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'service_mode', - 'unique_id': 'LRW3F7EK4NC700000-service_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_service_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Service mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_service_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_speed_limited-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_speed_limited', - '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': 'Speed limited', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'speed_limit_mode', - 'unique_id': 'LRW3F7EK4NC700000-speed_limit_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_speed_limited-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Speed limited', - }), - 'context': , - 'entity_id': 'binary_sensor.test_speed_limited', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_status-entry] @@ -2749,55 +1066,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', - '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': 'Supercharger session trip planner', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'supercharger_session_trip_planner', - 'unique_id': 'LRW3F7EK4NC700000-supercharger_session_trip_planner', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Supercharger session trip planner', - }), - 'context': , - 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-entry] @@ -3093,103 +1362,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_wi_fi-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wi-Fi', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wifi', - 'unique_id': 'LRW3F7EK4NC700000-wifi', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wi_fi-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Wi-Fi', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wiper_heat-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_wiper_heat', - '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': 'Wiper heat', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wiper_heat_enabled', - 'unique_id': 'LRW3F7EK4NC700000-wiper_heat_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wiper_heat-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Wiper heat', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wiper_heat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.energy_site_backup_capable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3256,32 +1428,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_automatic_blind_spot_camera-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic blind spot camera', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_automatic_emergency_braking_off-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic emergency braking off', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3293,46 +1439,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_blind_spot_collision_warning_chime-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Blind spot collision warning chime', - }), - 'context': , - 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_bms_full_charge-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test BMS full charge', - }), - 'context': , - 'entity_id': 'binary_sensor.test_bms_full_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_brake_pedal-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Brake pedal', - }), - 'context': , - 'entity_id': 'binary_sensor.test_brake_pedal', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_active-statealt] @@ -3349,20 +1456,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_cellular-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Cellular', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cellular', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_cable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3374,33 +1467,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_charge_enable_request-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge enable request', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_enable_request', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge port cold weather mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_charger_has_multiple_phases-statealt] @@ -3413,7 +1480,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_dashcam-statealt] @@ -3430,110 +1497,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_dc_to_dc_converter-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test DC to DC converter', - }), - 'context': , - 'entity_id': 'binary_sensor.test_dc_to_dc_converter', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_defrost_for_preconditioning-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Defrost for preconditioning', - }), - 'context': , - 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Drive rail', - }), - 'context': , - 'entity_id': 'binary_sensor.test_drive_rail', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_belt-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_occupied-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat occupied', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_occupied', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_emergency_lane_departure_avoidance-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Emergency lane departure avoidance', - }), - 'context': , - 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_european_vehicle-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test European vehicle', - }), - 'context': , - 'entity_id': 'binary_sensor.test_european_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_fast_charger_present-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Fast charger present', - }), - 'context': , - 'entity_id': 'binary_sensor.test_fast_charger_present', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_door-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3545,7 +1508,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_window-statealt] @@ -3559,7 +1522,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_door-statealt] @@ -3573,7 +1536,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_window-statealt] @@ -3587,178 +1550,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_gps_state-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test GPS state', - }), - 'context': , - 'entity_id': 'binary_sensor.test_gps_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_guest_mode_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Guest mode enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_guest_mode_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_hazard_lights-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Hazard lights', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hazard_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_high_beams-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test High beams', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_beams', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_high_voltage_interlock_loop_fault-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test High voltage interlock loop fault', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Homelink nearby', - }), - 'context': , - 'entity_id': 'binary_sensor.test_homelink_nearby', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_hvac_auto_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test HVAC auto mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hvac_auto_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_favorite-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at favorite', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_favorite', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_home-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at home', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_work-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at work', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_work', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Offroad lightbar', - }), - 'context': , - 'entity_id': 'binary_sensor.test_offroad_lightbar', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_passenger_seat_belt-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Passenger seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_passenger_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test PIN to Drive enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_preconditioning-statealt] @@ -3784,20 +1576,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_rear_display_hvac-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Rear display HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_display_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] @@ -3811,7 +1590,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_window-statealt] @@ -3825,7 +1604,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_door-statealt] @@ -3839,7 +1618,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_window-statealt] @@ -3853,33 +1632,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_remote_start-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Remote start', - }), - 'context': , - 'entity_id': 'binary_sensor.test_remote_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Right hand drive', - }), - 'context': , - 'entity_id': 'binary_sensor.test_right_hand_drive', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_scheduled_charging_pending-statealt] @@ -3892,46 +1645,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_seat_vent_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat vent enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_seat_vent_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Service mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_service_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_speed_limited-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Speed limited', - }), - 'context': , - 'entity_id': 'binary_sensor.test_speed_limited', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] @@ -3945,20 +1659,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_supercharger_session_trip_planner-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Supercharger session trip planner', - }), - 'context': , - 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_front_left-statealt] @@ -4044,33 +1745,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_wi_fi-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Wi-Fi', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_wiper_heat-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Wiper heat', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wiper_heat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensors_connectivity[binary_sensor.test_cellular-state] 'on' # --- diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 1aa68b59ee3..11708be7e39 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -407,9 +407,8 @@ ]), 'max_temp': 40, 'min_temp': 30, - 'supported_features': , + 'supported_features': , 'target_temp_step': 5, - 'temperature': None, }), 'context': , 'entity_id': 'climate.test_cabin_overheat_protection', diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index 0f5588fe323..b3871c52420 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -23,6 +23,7 @@ async def test_binary_sensor( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the binary sensor entities are correct.""" @@ -37,6 +38,7 @@ async def test_binary_sensor_refresh( entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, + mock_legacy: AsyncMock, ) -> None: """Tests that the binary sensor entities are correct.""" diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 27bed45c51f..f6c158fbd80 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -273,7 +273,6 @@ async def test_climate_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" mock_metadata.return_value = METADATA_NOSCOPE diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index e3933931c9f..2ba6d391cfc 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -55,7 +55,6 @@ async def test_cover_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct without scopes.""" @@ -67,6 +66,7 @@ async def test_cover_noscope( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_services( hass: HomeAssistant, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct.""" diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index ea0ee08e64f..7edabe9ec6f 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -49,7 +49,6 @@ async def test_device_tracker_noscope( entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, mock_vehicle_data: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" diff --git a/tests/components/teslemetry/test_diagnostics.py b/tests/components/teslemetry/test_diagnostics.py index 18182b14321..5737a5ebe2c 100644 --- a/tests/components/teslemetry/test_diagnostics.py +++ b/tests/components/teslemetry/test_diagnostics.py @@ -1,5 +1,7 @@ """Test the Telemetry Diagnostics.""" +from unittest.mock import AsyncMock + from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion @@ -18,6 +20,7 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, freezer: FrozenDateTimeFactory, + mock_legacy: AsyncMock, ) -> None: """Test diagnostics.""" diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 54c9ca0dad9..e177865d2f9 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -14,7 +14,13 @@ from tesla_fleet_api.exceptions import ( from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -72,6 +78,7 @@ async def test_vehicle_refresh_error( mock_vehicle_data: AsyncMock, side_effect: TeslaFleetError, state: ConfigEntryState, + mock_legacy: AsyncMock, ) -> None: """Test coordinator refresh with an error.""" mock_vehicle_data.side_effect = side_effect @@ -107,6 +114,7 @@ async def test_energy_site_refresh_error( assert entry.state is state +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_vehicle_stream( hass: HomeAssistant, mock_add_listener: AsyncMock, @@ -121,7 +129,7 @@ async def test_vehicle_stream( assert state.state == STATE_UNKNOWN state = hass.states.get("binary_sensor.test_user_present") - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE mock_add_listener.send( { diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index ab8f21ceda4..8b7a91cfe2c 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -55,7 +55,6 @@ async def test_media_player_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct without required scope.""" diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index 296f9e8bff4..e8f413433c1 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -1,6 +1,6 @@ """Test the Teslemetry sensor platform.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory import pytest @@ -26,6 +26,7 @@ async def test_sensors( entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the sensor entities with the legacy polling are correct.""" @@ -33,9 +34,7 @@ async def test_sensors( async_fire_time_changed(hass) await hass.async_block_till_done() - # Force the vehicle to use polling - with patch("tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True): - entry = await setup_platform(hass, [Platform.SENSOR]) + entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 6e68b354087..d2db9b094f5 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -92,12 +92,11 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("invalid_config", [None, 1, {"name with space": None}]) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index c8f54fa275d..d9016d18def 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -13,16 +13,24 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DEVICE_MOCKS = { - "am43_corded_motor_zigbee_cover": [ + "cl_am43_corded_motor_zigbee_cover": [ # https://github.com/home-assistant/core/issues/71242 - Platform.SELECT, Platform.COVER, + Platform.SELECT, ], "clkg_curtain_switch": [ # https://github.com/home-assistant/core/issues/136055 Platform.COVER, Platform.LIGHT, ], + "co2bj_air_detector": [ + # https://github.com/home-assistant/core/issues/133173 + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + ], "cs_arete_two_12l_dehumidifier_air_purifier": [ Platform.BINARY_SENSOR, Platform.FAN, @@ -31,6 +39,26 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "cs_emma_dehumidifier": [ + # https://github.com/home-assistant/core/issues/119865 + Platform.BINARY_SENSOR, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], + "cs_smart_dry_plus": [ + # https://github.com/home-assistant/core/issues/119865 + Platform.FAN, + Platform.HUMIDIFIER, + ], + "cwjwq_smart_odor_eliminator": [ + # https://github.com/orgs/home-assistant/discussions/79 + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], "cwwsq_cleverio_pf100": [ # https://github.com/home-assistant/core/issues/144745 Platform.NUMBER, @@ -46,6 +74,10 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "dj_smart_light_bulb": [ + # https://github.com/home-assistant/core/pull/126242 + Platform.LIGHT + ], "dlq_earu_electric_eawcpt": [ # https://github.com/home-assistant/core/issues/102769 Platform.SENSOR, @@ -55,6 +87,10 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], + "gyd_night_light": [ + # https://github.com/home-assistant/core/issues/133173 + Platform.LIGHT, + ], "kg_smart_valve": [ # https://github.com/home-assistant/core/issues/148347 Platform.SWITCH, @@ -65,9 +101,20 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "ks_tower_fan": [ + # https://github.com/orgs/home-assistant/discussions/329 + Platform.FAN, + Platform.LIGHT, + Platform.SWITCH, + ], + "kt_serenelife_slpac905wuk_air_conditioner": [ + # https://github.com/home-assistant/core/pull/148646 + Platform.CLIMATE, + ], "mal_alarm_host": [ # Alarm Host support Platform.ALARM_CONTROL_PANEL, + Platform.NUMBER, Platform.SWITCH, ], "mcs_door_sensor": [ @@ -75,6 +122,10 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "qccdz_ac_charging_control": [ + # https://github.com/home-assistant/core/issues/136207 + Platform.SWITCH, + ], "qxj_temp_humidity_external_probe": [ # https://github.com/home-assistant/core/issues/136472 Platform.SENSOR, @@ -100,6 +151,8 @@ DEVICE_MOCKS = { "wk_wifi_smart_gas_boiler_thermostat": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, Platform.SWITCH, ], "wsdcg_temperature_humidity": [ diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 3d89e1d6f92..cac9359a8d3 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -180,4 +180,7 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev for key, value in details["status_range"].items() } device.status = details["status"] + for key, value in device.status.items(): + if device.status_range[key].type == "Json": + device.status[key] = json_dumps(value) return device diff --git a/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json b/tests/components/tuya/fixtures/cl_am43_corded_motor_zigbee_cover.json similarity index 100% rename from tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json rename to tests/components/tuya/fixtures/cl_am43_corded_motor_zigbee_cover.json diff --git a/tests/components/tuya/fixtures/co2bj_air_detector.json b/tests/components/tuya/fixtures/co2bj_air_detector.json new file mode 100644 index 00000000000..8d7e744fb52 --- /dev/null +++ b/tests/components/tuya/fixtures/co2bj_air_detector.json @@ -0,0 +1,174 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "1732306182276g6jQLp", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "eb14fd1dd93ca2ea34vpin", + "name": "AQI", + "category": "co2bj", + "product_id": "yrr3eiyiacm31ski", + "product_name": "AIR_DETECTOR ", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2025-01-02T05:14:50+00:00", + "create_time": "2025-01-02T05:14:50+00:00", + "update_time": "2025-01-02T05:14:50+00:00", + "function": { + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 1, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + }, + "alarm_bright": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "co2_state": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "co2_value": { + "type": "Integer", + "value": { + "unit": "ppm", + "min": 0, + "max": 5000, + "scale": 0, + "step": 1 + } + }, + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 1, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "alarm_bright": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -9, + "max": 199, + "scale": 0, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "pm25_value": { + "type": "Integer", + "value": { + "unit": "ug/m3", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "voc_value": { + "type": "Integer", + "value": { + "unit": "mg/m3", + "min": 0, + "max": 9999, + "scale": 3, + "step": 1 + } + }, + "ch2o_value": { + "type": "Integer", + "value": { + "unit": "mg/m3", + "min": 0, + "max": 9999, + "scale": 3, + "step": 1 + } + } + }, + "status": { + "co2_state": "normal", + "co2_value": 541, + "alarm_volume": "low", + "alarm_time": 1, + "alarm_switch": false, + "battery_percentage": 100, + "alarm_bright": 98, + "temp_current": 26, + "humidity_value": 53, + "pm25_value": 17, + "voc_value": 18, + "ch2o_value": 2 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_emma_dehumidifier.json b/tests/components/tuya/fixtures/cs_emma_dehumidifier.json new file mode 100644 index 00000000000..8a2fd881262 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_emma_dehumidifier.json @@ -0,0 +1,129 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Dehumidifer", + "category": "cs", + "product_id": "ka2wfrdoogpvgzfi", + "product_name": "Emma Dehumidifier - eeese air care", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-11-06T18:25:00+00:00", + "create_time": "2024-11-06T18:25:00+00:00", + "update_time": "2024-11-06T18:25:00+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 25, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 25, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "humidity_indoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "h", + "min": 0, + "max": 24, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["tankfull", "defrost", "E1", "E2", "L3", "L4", "L2"] + } + } + }, + "status": { + "switch": false, + "dehumidify_set_value": 25, + "fan_speed_enum": "low", + "anion": false, + "child_lock": false, + "humidity_indoor": 48, + "countdown_set": "cancel", + "countdown_left": 0, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_smart_dry_plus.json b/tests/components/tuya/fixtures/cs_smart_dry_plus.json new file mode 100644 index 00000000000..ff922f506c5 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_smart_dry_plus.json @@ -0,0 +1,32 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Dehumidifier ", + "category": "cs", + "product_id": "vmxuxszzjwp5smli", + "product_name": "the Smart Dry Plus\u2122 Connect Dehumidifier ", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2024-05-28T01:57:58+00:00", + "create_time": "2024-05-28T01:57:58+00:00", + "update_time": "2024-05-28T01:57:58+00:00", + "function": {}, + "status_range": { + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json b/tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json new file mode 100644 index 00000000000..a4a9fc6aaff --- /dev/null +++ b/tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json @@ -0,0 +1,66 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1750837476328i3TNXQ", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf6574iutyikgwkx", + "name": "Smart Odor Eliminator-Pro", + "category": "cwjwq", + "product_id": "agwu93lr", + "product_name": "Smart Odor Eliminator-Pro", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-25T07:43:07+00:00", + "create_time": "2025-06-25T07:43:07+00:00", + "update_time": "2025-06-25T07:43:07+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["smart", "interim"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["smart", "interim"] + } + }, + "work_state_e": { + "type": "Enum", + "value": { + "range": ["work", "standby", "charging", "charge_done"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "work_mode": "smart", + "work_state_e": "work", + "battery_percentage": 43 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_smart_light_bulb.json b/tests/components/tuya/fixtures/dj_smart_light_bulb.json new file mode 100644 index 00000000000..49854adc889 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_smart_light_bulb.json @@ -0,0 +1,458 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "REDACTED", + "name": "Garage light", + "category": "dj", + "product_id": "mki13ie507rlry4r", + "product_name": "Smart Light Bulb", + "online": true, + "sub": false, + "time_zone": "-07:00", + "active_time": "2024-06-15T19:53:11+00:00", + "create_time": "2024-06-15T19:53:11+00:00", + "update_time": "2024-06-15T19:53:11+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 546, + "colour_data_v2": { + "h": 243, + "s": 860, + "v": 541 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 1000, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/gyd_night_light.json b/tests/components/tuya/fixtures/gyd_night_light.json new file mode 100644 index 00000000000..28f2b8e8f46 --- /dev/null +++ b/tests/components/tuya/fixtures/gyd_night_light.json @@ -0,0 +1,266 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "1732306182276g6jQLp", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + + "id": "eb3e988f33c233290cfs3l", + "name": "Colorful PIR Night Light", + "category": "gyd", + "product_id": "lgekqfxdabipm3tn", + "product_name": "Colorful PIR Night Light", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2024-07-18T12:02:37+00:00", + "create_time": "2024-07-18T12:02:37+00:00", + "update_time": "2024-07-18T12:02:37+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "scene_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "cds": { + "type": "Enum", + "value": { + "range": ["2000lux", "300lux", "50lux", "10lux", "5lux"] + } + }, + "pir_sensitivity": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "pir_delay": { + "type": "Integer", + "value": { + "unit": "s", + "min": 5, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "switch_pir": { + "type": "Boolean", + "value": {} + }, + "standby_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 480, + "scale": 0, + "step": 1 + } + }, + "standby_bright": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "scene_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "pir_state": { + "type": "Enum", + "value": { + "range": ["pir", "none"] + } + }, + "cds": { + "type": "Enum", + "value": { + "range": ["2000lux", "300lux", "50lux", "10lux", "5lux"] + } + }, + "pir_sensitivity": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "pir_delay": { + "type": "Integer", + "value": { + "unit": "s", + "min": 5, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "switch_pir": { + "type": "Boolean", + "value": {} + }, + "standby_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 480, + "scale": 0, + "step": 1 + } + }, + "standby_bright": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value": 1000, + "temp_value": 1, + "colour_data": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown": 0, + "device_mode": "auto", + "pir_state": "none", + "cds": "5lux", + "pir_sensitivity": "middle", + "pir_delay": 30, + "switch_pir": true, + "standby_time": 1, + "standby_bright": 146 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ks_tower_fan.json b/tests/components/tuya/fixtures/ks_tower_fan.json new file mode 100644 index 00000000000..071596e8e6c --- /dev/null +++ b/tests/components/tuya/fixtures/ks_tower_fan.json @@ -0,0 +1,107 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Tower Fan CA-407G Smart", + "category": "ks", + "product_id": "j9fa8ahzac8uvlfl", + "product_name": "Tower Fan CA-407G Smart", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-14T11:22:54+00:00", + "create_time": "2025-07-14T11:22:54+00:00", + "update_time": "2025-07-14T11:22:54+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "fan_speed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 12, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["ordinary", "nature", "sleep"] + } + }, + "switch_horizontal": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "fan_speed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 12, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["ordinary", "nature", "sleep"] + } + }, + "switch_horizontal": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 721, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "fan_speed": 5, + "mode": "ordinary", + "switch_horizontal": true, + "anion": false, + "light": true, + "countdown_left": 0 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json b/tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json new file mode 100644 index 00000000000..8fa2d7b0512 --- /dev/null +++ b/tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json @@ -0,0 +1,80 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Air Conditioner", + "category": "kt", + "product_id": "5wnlzekkstwcdsvm", + "product_name": "\u79fb\u52a8\u7a7a\u8c03 YPK--\uff08\u53cc\u6a21+\u84dd\u7259\uff09\u4f4e\u529f\u8017", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-07-06T10:10:44+00:00", + "create_time": "2025-07-06T10:10:44+00:00", + "update_time": "2025-07-06T10:10:44+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": -7, + "max": 98, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status": { + "switch": false, + "temp_set": 23, + "temp_current": 22, + "windspeed": 1 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qccdz_ac_charging_control.json b/tests/components/tuya/fixtures/qccdz_ac_charging_control.json new file mode 100644 index 00000000000..1ae5e966de7 --- /dev/null +++ b/tests/components/tuya/fixtures/qccdz_ac_charging_control.json @@ -0,0 +1,105 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1737479380414pasuj4", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf83514d9c14b426f0fz5y", + "name": "AC charging control box", + "category": "qccdz", + "product_id": "7bvgooyjhiua1yyq", + "product_name": "AC charging control box", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-21T17:00:03+00:00", + "create_time": "2025-01-21T17:00:03+00:00", + "update_time": "2025-01-21T17:00:03+00:00", + "function": { + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "charge_now", + "charge_pct", + "charge_energy", + "charge_schedule" + ] + } + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "work_state": { + "type": "Enum", + "value": { + "range": [ + "charger_free", + "charger_insert", + "charger_free_fault", + "charger_wait", + "charger_charging", + "charger_pause", + "charger_end", + "charger_fault" + ] + } + }, + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "charge_now", + "charge_pct", + "charge_energy", + "charge_schedule" + ] + } + }, + "balance_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 3, + "step": 1 + } + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "charge_energy_once": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 1, + "max": 999999, + "scale": 2, + "step": 1 + } + } + }, + "status": { + "work_state": "charger_free", + "work_mode": "charge_now", + "balance_energy": 0, + "clear_energy": false, + "switch": false, + "charge_energy_once": 1 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index efd995b3280..267f61aabd0 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][binary_sensor.aqi_safety-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.aqi_safety', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Safety', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinco2_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][binary_sensor.aqi_safety-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'safety', + 'friendly_name': 'AQI Safety', + }), + 'context': , + 'entity_id': 'binary_sensor.aqi_safety', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_defrost-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -146,6 +195,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_defrost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dehumidifer_defrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrost', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'defrost', + 'unique_id': 'tuya.mock_device_iddefrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_defrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifer Defrost', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifer_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_tank_full-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dehumidifer_tank_full', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank full', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tankfull', + 'unique_id': 'tuya.mock_device_idtankfull', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_tank_full-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifer Tank full', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifer_tank_full', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[mcs_door_sensor][binary_sensor.door_garage_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 4360ef7f436..42fc10fef54 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -1,4 +1,79 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + '1', + '2', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'fan_mode': 1, + 'fan_modes': list([ + '1', + '2', + ]), + 'friendly_name': 'Air Conditioner', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 23.0, + }), + 'context': , + 'entity_id': 'climate.air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index 1ab635919ca..6ae4781c7c1 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-entry] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-state] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 48, diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index cbd3c997625..ff795c150c9 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -49,6 +49,110 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][fan.dehumidifer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][fan.dehumidifer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer', + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][fan.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][fan.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][fan.bree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -106,3 +210,67 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[ks_tower_fan][fan.tower_fan_ca_407g_smart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'ordinary', + 'nature', + 'sleep', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.tower_fan_ca_407g_smart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ks_tower_fan][fan.tower_fan_ca_407g_smart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tower Fan CA-407G Smart', + 'oscillating': True, + 'percentage': 37, + 'percentage_step': 1.0, + 'preset_mode': 'ordinary', + 'preset_modes': list([ + 'ordinary', + 'nature', + 'sleep', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.tower_fan_ca_407g_smart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index c22005e123d..3389f927eb4 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -56,3 +56,113 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][humidifier.dehumidifer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 80, + 'min_humidity': 25, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_idswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][humidifier.dehumidifer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifer', + 'max_humidity': 80, + 'min_humidity': 25, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][humidifier.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_idswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][humidifier.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifier ', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index b9395b3d682..c691aae2cc1 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -56,3 +56,204 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[dj_smart_light_bulb][light.garage_light-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.garage_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.REDACTEDswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_smart_light_bulb][light.garage_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 138, + 'color_mode': , + 'friendly_name': 'Garage light', + 'hs_color': tuple( + 243.0, + 86.0, + ), + 'rgb_color': tuple( + 47, + 36, + 255, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.148, + 0.055, + ), + }), + 'context': , + 'entity_id': 'light.garage_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[gyd_night_light][light.colorful_pir_night_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.colorful_pir_night_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.eb3e988f33c233290cfs3lswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[gyd_night_light][light.colorful_pir_night_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Colorful PIR Night Light', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.colorful_pir_night_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[ks_tower_fan][light.tower_fan_ca_407g_smart_backlight-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': , + 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + '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': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.mock_device_idlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ks_tower_fan][light.tower_fan_ca_407g_smart_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Tower Fan CA-407G Smart Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 6d741e4e76c..1c8af00baff 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -1,4 +1,63 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][number.aqi_alarm_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqi_alarm_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_duration', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][number.aqi_alarm_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'AQI Alarm duration', + 'max': 60.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.aqi_alarm_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][number.cleverio_pf100_feed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -36,7 +95,7 @@ 'supported_features': 0, 'translation_key': 'feed', 'unique_id': 'tuya.bfd0273e59494eb34esvrxmanual_feed', - 'unit_of_measurement': None, + 'unit_of_measurement': '', }) # --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][number.cleverio_pf100_feed-state] @@ -47,6 +106,7 @@ 'min': 1.0, 'mode': , 'step': 1.0, + 'unit_of_measurement': '', }), 'context': , 'entity_id': 'number.cleverio_pf100_feed', @@ -56,3 +116,238 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_alarm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_alarm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_delay', + 'unique_id': 'tuya.123123aba12312312dazubalarm_delay_time', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Alarm delay', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_arm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Arm delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'arm_delay', + 'unique_id': 'tuya.123123aba12312312dazubdelay_set', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_arm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Arm delay', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_siren_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_siren_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Siren duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren_duration', + 'unique_id': 'tuya.123123aba12312312dazubalarm_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_siren_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Siren duration', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_siren_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.bfb45cb8a9452fba66lexgtemp_correction', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Temperature correction', + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.5', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index e8337fb4fbf..0f530184122 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-entry] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -39,7 +39,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-state] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Kitchen Blinds Motor mode', @@ -56,6 +56,67 @@ 'state': 'forward', }) # --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][select.aqi_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aqi_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][select.aqi_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI Volume', + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'context': , + 'entity_id': 'select.aqi_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][select.dehumidifier_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -117,6 +178,124 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][select.dehumidifer_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dehumidifer_countdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.mock_device_idcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][select.dehumidifer_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.dehumidifer_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][select.smart_odor_eliminator_pro_odor_elimination_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart', + 'interim', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Odor elimination mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odor_elimination_mode', + 'unique_id': 'tuya.bf6574iutyikgwkxwork_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][select.smart_odor_eliminator_pro_odor_elimination_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Odor elimination mode', + 'options': list([ + 'smart', + 'interim', + ]), + }), + 'context': , + 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 8cf51062a73..57e73eccda5 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1,4 +1,271 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aqi_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'AQI Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aqi_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_formaldehyde-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.aqi_formaldehyde', + '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': 'Formaldehyde', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'formaldehyde', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinch2o_value', + 'unit_of_measurement': 'mg/m3', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_formaldehyde-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI Formaldehyde', + 'state_class': , + 'unit_of_measurement': 'mg/m3', + }), + 'context': , + 'entity_id': 'sensor.aqi_formaldehyde', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.002', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aqi_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AQI Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aqi_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.0', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aqi_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpintemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AQI Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aqi_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.0', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_volatile_organic_compounds-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.aqi_volatile_organic_compounds', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voc', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinvoc_value', + 'unit_of_measurement': 'mg/m³', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_volatile_organic_compounds-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds', + 'friendly_name': 'AQI Volatile organic compounds', + 'state_class': , + 'unit_of_measurement': 'mg/m³', + }), + 'context': , + 'entity_id': 'sensor.aqi_volatile_organic_compounds', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.018', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][sensor.dehumidifier_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -52,6 +319,160 @@ 'state': '47.0', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][sensor.dehumidifer_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dehumidifer_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.mock_device_idhumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][sensor.dehumidifer_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dehumidifer Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dehumidifer_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf6574iutyikgwkxbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smart Odor Eliminator-Pro Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_odor_eliminator_pro_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odor_elimination_status', + 'unique_id': 'tuya.bf6574iutyikgwkxwork_state_e', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Status', + }), + 'context': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][sensor.cleverio_pf100_last_amount-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -128,8 +549,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Filter duration', 'platform': 'tuya', @@ -144,6 +568,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_filter_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain Filter duration', 'state_class': , 'unit_of_measurement': 'min', @@ -180,8 +605,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'UV runtime', 'platform': 'tuya', @@ -196,6 +624,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_uv_runtime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain UV runtime', 'state_class': , 'unit_of_measurement': 's', @@ -280,8 +709,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Water pump duration', 'platform': 'tuya', @@ -296,6 +728,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_pump_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain Water pump duration', 'state_class': , 'unit_of_measurement': 'min', @@ -332,8 +765,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Water usage duration', 'platform': 'tuya', @@ -348,6 +784,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_usage_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain Water usage duration', 'state_class': , 'unit_of_measurement': 'min', @@ -456,7 +893,7 @@ 'supported_features': 0, 'translation_key': 'power', 'unique_id': 'tuya.eb0c772dabbb19d653ssi5cur_power', - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }) # --- # name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_power-state] @@ -465,7 +902,7 @@ 'device_class': 'power', 'friendly_name': 'HVAC Meter Power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }), 'context': , 'entity_id': 'sensor.hvac_meter_power', @@ -630,7 +1067,7 @@ 'supported_features': 0, 'translation_key': 'power', 'unique_id': 'tuya.mocked_device_idcur_power', - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }) # --- # name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-state] @@ -639,7 +1076,7 @@ 'device_class': 'power', 'friendly_name': '一路带计量磁保持通断器 Power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }), 'context': , 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', @@ -1796,6 +2233,59 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][sensor.wifi_smart_gas_boiler_thermostat_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.wifi_smart_gas_boiler_thermostat_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bfb45cb8a9452fba66lexgbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][sensor.wifi_smart_gas_boiler_thermostat_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.wifi_smart_gas_boiler_thermostat_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr new file mode 100644 index 00000000000..8a6faa31c43 --- /dev/null +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][siren.aqi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': , + 'entity_id': 'siren.aqi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][siren.aqi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.aqi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index bf970a6ffbb..dc47486e980 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -48,6 +48,152 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.dehumidifer_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.mock_device_idchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.dehumidifer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_ionizer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.dehumidifer_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:atom', + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.mock_device_idanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.dehumidifer_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][switch.smart_odor_eliminator_pro_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.smart_odor_eliminator_pro_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.bf6574iutyikgwkxswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][switch.smart_odor_eliminator_pro_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Switch', + }), + 'context': , + 'entity_id': 'switch.smart_odor_eliminator_pro_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_filter_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -579,6 +725,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[ks_tower_fan][switch.tower_fan_ca_407g_smart_ionizer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.tower_fan_ca_407g_smart_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.mock_device_idanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ks_tower_fan][switch.tower_fan_ca_407g_smart_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tower Fan CA-407G Smart Ionizer', + }), + 'context': , + 'entity_id': 'switch.tower_fan_ca_407g_smart_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_arm_beep-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -675,6 +869,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[qccdz_ac_charging_control][switch.ac_charging_control_box_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ac_charging_control_box_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.bf83514d9c14b426f0fz5yswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qccdz_ac_charging_control][switch.ac_charging_control_box_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC charging control box Switch', + }), + 'context': , + 'entity_id': 'switch.ac_charging_control_box_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index a5117983000..9c0e3c31a26 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -8,9 +8,14 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -55,3 +60,68 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_serenelife_slpac905wuk_air_conditioner"], +) +async def test_fan_mode_windspeed( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + entity_id = "climate.air_conditioner" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + assert state.attributes["fan_mode"] == 1 + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + "entity_id": entity_id, + "fan_mode": 2, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "windspeed", "value": "2"}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_serenelife_slpac905wuk_air_conditioner"], +) +async def test_fan_mode_no_valid_code( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with no valid code.""" + # Remove windspeed DPCode to simulate a device with no valid fan mode + mock_device.function.pop("windspeed", None) + mock_device.status_range.pop("windspeed", None) + mock_device.status.pop("windspeed", None) + + entity_id = "climate.air_conditioner" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + assert state.attributes.get("fan_mode") is None + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + "entity_id": entity_id, + "fan_mode": 2, + }, + blocking=True, + ) diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 4550ed9d6f4..29a6d65978f 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -59,7 +59,7 @@ async def test_platform_setup_no_discovery( @pytest.mark.parametrize( "mock_device_code", - ["am43_corded_motor_zigbee_cover"], + ["cl_am43_corded_motor_zigbee_cover"], ) @pytest.mark.parametrize( ("percent_control", "percent_state"), @@ -83,8 +83,9 @@ async def test_percent_state_on_cover( # 100 is closed and 0 is open for Tuya covers mock_device.status["percent_state"] = 100 - percent_state + entity_id = "cover.kitchen_blinds_curtain" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - cover_state = hass.states.get("cover.kitchen_blinds_curtain") - assert cover_state is not None, "cover.kitchen_blinds_curtain does not exist" - assert cover_state.attributes["current_position"] == percent_state + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + assert state.attributes["current_position"] == percent_state diff --git a/tests/components/tuya/test_siren.py b/tests/components/tuya/test_siren.py new file mode 100644 index 00000000000..69ccc14e407 --- /dev/null +++ b/tests/components/tuya/test_siren.py @@ -0,0 +1,55 @@ +"""Test Tuya siren platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SIREN in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SIREN not in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 3064c66f009..3156327f1a5 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -12,7 +12,6 @@ from uiprotect.data import NVR, Bootstrap, CloudAccount, Light from homeassistant.components.unifiprotect.const import ( AUTH_RETRIES, CONF_ALLOW_EA, - CONF_DISABLE_RTSP, DOMAIN, ) from homeassistant.components.unifiprotect.data import ( @@ -87,22 +86,6 @@ async def test_setup_multiple( assert mock_config.unique_id == ufp.api.bootstrap.nvr.mac -async def test_reload(hass: HomeAssistant, ufp: MockUFPFixture) -> None: - """Test updating entry reload entry.""" - - await hass.config_entries.async_setup(ufp.entry.entry_id) - await hass.async_block_till_done() - assert ufp.entry.state is ConfigEntryState.LOADED - - options = dict(ufp.entry.options) - options[CONF_DISABLE_RTSP] = True - hass.config_entries.async_update_entry(ufp.entry, options=options) - await hass.async_block_till_done() - - assert ufp.entry.state is ConfigEntryState.LOADED - assert ufp.api.async_disconnect_ws.called - - async def test_unload(hass: HomeAssistant, ufp: MockUFPFixture, light: Light) -> None: """Test unloading of unifiprotect entry.""" diff --git a/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr b/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..97e40e821da --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + '1': dict({ + 'monitor_cert_days_remaining': 90, + 'monitor_cert_is_valid': 1, + 'monitor_hostname': None, + 'monitor_id': 1, + 'monitor_name': 'Monitor 1', + 'monitor_port': None, + 'monitor_response_time': 120, + 'monitor_status': 1, + 'monitor_type': 'http', + 'monitor_url': '**REDACTED**', + }), + '2': dict({ + 'monitor_cert_days_remaining': 0, + 'monitor_cert_is_valid': 0, + 'monitor_hostname': None, + 'monitor_id': 2, + 'monitor_name': 'Monitor 2', + 'monitor_port': None, + 'monitor_response_time': 28, + 'monitor_status': 1, + 'monitor_type': 'port', + 'monitor_url': None, + }), + '3': dict({ + 'monitor_cert_days_remaining': 90, + 'monitor_cert_is_valid': 1, + 'monitor_hostname': None, + 'monitor_id': 3, + 'monitor_name': 'Monitor 3', + 'monitor_port': None, + 'monitor_response_time': 120, + 'monitor_status': 0, + 'monitor_type': 'json-query', + 'monitor_url': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py index b70cb9d353c..ab695107b9b 100644 --- a/tests/components/uptime_kuma/test_config_flow.py +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -120,3 +120,163 @@ async def test_form_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "newapikey" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reauth_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test reauth flow errors and recover.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_pythonkuma.metrics.side_effect = raise_error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "newapikey" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reconfigure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + } + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors and recover.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pythonkuma.metrics.side_effect = raise_error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + } + + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/uptime_kuma/test_diagnostics.py b/tests/components/uptime_kuma/test_diagnostics.py new file mode 100644 index 00000000000..92d98d49b75 --- /dev/null +++ b/tests/components/uptime_kuma/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests Uptime Kuma diagnostics platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/uptime_kuma/test_init.py b/tests/components/uptime_kuma/test_init.py index 57390da60d5..6e2ef43b14d 100644 --- a/tests/components/uptime_kuma/test_init.py +++ b/tests/components/uptime_kuma/test_init.py @@ -5,7 +5,8 @@ from unittest.mock import AsyncMock import pytest from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaException -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -50,3 +51,29 @@ async def test_config_entry_not_ready( await hass.async_block_till_done() assert config_entry.state is state + + +async def test_config_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, +) -> None: + """Test config entry auth error starts reauth flow.""" + + mock_pythonkuma.metrics.side_effect = UptimeKumaAuthenticationException + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id diff --git a/tests/components/waqi/__init__.py b/tests/components/waqi/__init__.py index b6f36680ee3..be808875df8 100644 --- a/tests/components/waqi/__init__.py +++ b/tests/components/waqi/__init__.py @@ -1 +1,13 @@ """Tests for the World Air Quality Index (WAQI) integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/waqi/conftest.py b/tests/components/waqi/conftest.py index 75709d4f56e..bb64fdef097 100644 --- a/tests/components/waqi/conftest.py +++ b/tests/components/waqi/conftest.py @@ -1,14 +1,16 @@ """Common fixtures for the World Air Quality Index (WAQI) tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, patch +from aiowaqi import WAQIAirQuality import pytest from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture @@ -29,3 +31,28 @@ def mock_config_entry() -> MockConfigEntry: title="de Jongweg, Utrecht", data={CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584}, ) + + +@pytest.fixture +async def mock_waqi(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: + """Mock WAQI client.""" + with ( + patch( + "homeassistant.components.waqi.WAQIClient", + autospec=True, + ) as mock_waqi, + patch( + "homeassistant.components.waqi.config_flow.WAQIClient", + new=mock_waqi, + ), + ): + client = mock_waqi.return_value + air_quality = WAQIAirQuality.from_dict( + await async_load_json_object_fixture( + hass, "air_quality_sensor.json", DOMAIN + ) + ) + client.get_by_station_number.return_value = air_quality + client.get_by_ip.return_value = air_quality + client.get_by_coordinates.return_value = air_quality + yield client diff --git a/tests/components/waqi/snapshots/test_sensor.ambr b/tests/components/waqi/snapshots/test_sensor.ambr index 08e58a74524..d0c46346b2e 100644 --- a/tests/components/waqi/snapshots/test_sensor.ambr +++ b/tests/components/waqi/snapshots/test_sensor.ambr @@ -1,5 +1,42 @@ # serializer version: 1 -# name: test_sensor +# name: test_sensor[sensor.de_jongweg_utrecht_air_quality_index-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.de_jongweg_utrecht_air_quality_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality index', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_air_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_air_quality_index-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -15,39 +52,104 @@ 'state': '29', }) # --- -# name: test_sensor.1 +# name: test_sensor[sensor.de_jongweg_utrecht_carbon_monoxide-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.de_jongweg_utrecht_carbon_monoxide', + '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': 'Carbon monoxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_monoxide', + 'unique_id': '4584_carbon_monoxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_carbon_monoxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'device_class': 'humidity', - 'friendly_name': 'de Jongweg, Utrecht Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }) -# --- -# name: test_sensor.10 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry', + 'friendly_name': 'de Jongweg, Utrecht Carbon monoxide', 'state_class': , }), 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', + 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '80', + 'state': '2.3', }) # --- -# name: test_sensor.11 +# name: test_sensor[sensor.de_jongweg_utrecht_dominant_pollutant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'co', + 'no2', + 'o3', + 'so2', + 'pm10', + 'pm25', + 'neph', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_dominant_pollutant', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dominant pollutant', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dominant_pollutant', + 'unique_id': '4584_dominant_pollutant', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_dominant_pollutant-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -71,7 +173,309 @@ 'state': 'o3', }) # --- -# name: test_sensor.2 +# name: test_sensor[sensor.de_jongweg_utrecht_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'humidity', + 'friendly_name': 'de Jongweg, Utrecht Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_nitrogen_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', + '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': 'Nitrogen dioxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nitrogen_dioxide', + 'unique_id': '4584_nitrogen_dioxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_nitrogen_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Nitrogen dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_ozone-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.de_jongweg_utrecht_ozone', + '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': 'Ozone', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ozone', + 'unique_id': '4584_ozone', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_ozone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Ozone', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_ozone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.4', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_pm10', + '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': 'PM10', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm10', + 'unique_id': '4584_pm10', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM10', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', + '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': 'PM2.5', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': '4584_pm25', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM2.5', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_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.de_jongweg_utrecht_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': 'Pressure', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -88,7 +492,99 @@ 'state': '1008.8', }) # --- -# name: test_sensor.3 +# name: test_sensor[sensor.de_jongweg_utrecht_sulphur_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', + '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': 'Sulphur dioxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sulphur_dioxide', + 'unique_id': '4584_sulphur_dioxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_sulphur_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Sulphur dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -105,93 +601,55 @@ 'state': '16', }) # --- -# name: test_sensor.4 +# name: test_sensor[sensor.de_jongweg_utrecht_visibility_using_nephelometry-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.de_jongweg_utrecht_visibility_using_nephelometry', + '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': 'Visibility using nephelometry', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'neph', + 'unique_id': '4584_neph', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_visibility_using_nephelometry-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Carbon monoxide', + 'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry', 'state_class': , }), 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', + 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.5 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Nitrogen dioxide', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.6 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Ozone', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_ozone', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '29.4', - }) -# --- -# name: test_sensor.7 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Sulphur dioxide', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.8 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht PM10', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_pm10', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '12', - }) -# --- -# name: test_sensor.9 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht PM2.5', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', + 'state': '80', }) # --- diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index a3fa47abc67..03759f96ff5 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -1,15 +1,14 @@ """Test the World Air Quality Index (WAQI) config flow.""" -import json from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock -from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError +from aiowaqi import WAQIAuthenticationError, WAQIConnectionError import pytest -from homeassistant import config_entries from homeassistant.components.waqi.config_flow import CONF_MAP from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -20,10 +19,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import async_load_fixture - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - @pytest.mark.parametrize( ("method", "payload"), @@ -45,63 +40,28 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_full_map_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, method: str, payload: dict[str, Any], ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: method}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == method - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "de Jongweg, Utrecht" @@ -109,6 +69,7 @@ async def test_full_map_flow( CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584, } + assert result["result"].unique_id == "4584" assert len(mock_setup_entry.mock_calls) == 1 @@ -121,73 +82,43 @@ async def test_full_map_flow( ], ) async def test_flow_errors( - hass: HomeAssistant, exception: Exception, error: str + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, + exception: Exception, + error: str, ) -> None: """Test we handle errors during configuration.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - side_effect=exception, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, - ) - await hass.async_block_till_done() + mock_waqi.get_by_ip.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, - ) - await hass.async_block_till_done() + mock_waqi.get_by_ip.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "map" - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -232,6 +163,7 @@ async def test_flow_errors( async def test_error_in_second_step( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, method: str, payload: dict[str, Any], exception: Exception, @@ -239,74 +171,36 @@ async def test_error_in_second_step( ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: method}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == method - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch("aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception), - patch("aiowaqi.WAQIClient.get_by_station_number", side_effect=exception), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + mock_waqi.get_by_coordinates.side_effect = exception + mock_waqi.get_by_station_number.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + mock_waqi.get_by_coordinates.side_effect = None + mock_waqi.get_by_station_number.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "de Jongweg, Utrecht" diff --git a/tests/components/waqi/test_init.py b/tests/components/waqi/test_init.py new file mode 100644 index 00000000000..7e4487f8ad2 --- /dev/null +++ b/tests/components/waqi/test_init.py @@ -0,0 +1,24 @@ +"""Test the World Air Quality Index (WAQI) initialization.""" + +from unittest.mock import AsyncMock + +from aiowaqi import WAQIError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waqi: AsyncMock, +) -> None: + """Test setup failure due to API error.""" + mock_waqi.get_by_station_number.side_effect = WAQIError("API error") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 7cd045604c8..d6e14d2dd54 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -1,59 +1,27 @@ """Test the World Air Quality Index (WAQI) sensor.""" -import json -from unittest.mock import patch +from unittest.mock import AsyncMock -from aiowaqi import WAQIAirQuality, WAQIError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.waqi.const import DOMAIN -from homeassistant.components.waqi.sensor import SENSORS -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, async_load_fixture +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_waqi: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test failed update.""" - mock_config_entry.add_to_hass(hass) - with patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - for sensor in SENSORS: - entity_id = entity_registry.async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, f"4584_{sensor.key}" - ) - assert hass.states.get(entity_id) == snapshot + """Test the World Air Quality Index (WAQI) sensor.""" + await setup_integration(hass, mock_config_entry) - -async def test_updating_failed( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test failed update.""" - mock_config_entry.add_to_hass(hass) - with patch( - "aiowaqi.WAQIClient.get_by_station_number", - side_effect=WAQIError(), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py index cd8f443c8fd..d7fb12c2848 100644 --- a/tests/components/webostv/test_init.py +++ b/tests/components/webostv/test_init.py @@ -54,6 +54,7 @@ async def test_update_options(hass: HomeAssistant, client) -> None: new_options = config_entry.options.copy() new_options[CONF_SOURCES] = ["Input02", "Live TV"] hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 57635a8cb74..90e731f3fe9 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -373,6 +373,7 @@ async def test_single_segment_with_keep_main_light( hass.config_entries.async_update_entry( init_integration, options={CONF_KEEP_MAIN_LIGHT: True} ) + await hass.config_entries.async_reload(init_integration.entry_id) await hass.async_block_till_done() assert (state := hass.states.get("light.wled_rgb_light_main")) diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index f288c340d9f..653b6810197 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -45,6 +45,7 @@ async def test_update_options( new_options["add_holidays"] = ["2023-04-12"] hass.config_entries.async_update_entry(entry, options=new_options) + await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() entry_check = hass.config_entries.async_get_entry("1") diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index bac0162ba74..6359f4bf5e7 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2520,7 +2520,7 @@ async def test_subscribe_rebuild_routes_progress( { "source": "controller", "event": "rebuild routes progress", - "progress": {67: "pending"}, + "progress": {"67": "pending"}, }, ) client.driver.controller.receive_event(event) @@ -2564,7 +2564,7 @@ async def test_subscribe_rebuild_routes_progress_initial_value( { "source": "controller", "event": "rebuild routes progress", - "progress": {67: "pending"}, + "progress": {"67": "pending"}, }, ) client.driver.controller.receive_event(event) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 324a0f14941..930f27e73f0 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -514,8 +514,8 @@ async def test_on_node_added_not_ready( assert len(device.identifiers) == 1 entities = er.async_entries_for_device(entity_registry, device.id) - # the only entities are the node status sensor, last_seen sensor, and ping button - assert len(entities) == 3 + # the only entities are the node status sensor, and ping button + assert len(entities) == 2 async def test_existing_node_ready( @@ -631,8 +631,8 @@ async def test_existing_node_not_ready( assert len(device.identifiers) == 1 entities = er.async_entries_for_device(entity_registry, device.id) - # the only entities are the node status sensor, last_seen sensor, and ping button - assert len(entities) == 3 + # the only entities are the node status sensor, and ping button + assert len(entities) == 2 async def test_existing_node_not_replaced_when_not_ready( diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d8c3de92b3b..d783e3deaba 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -34,7 +34,7 @@ async def _trigger_repair_issue( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) with patch( diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index ef77e22bbec..c7b41449d43 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -247,7 +247,7 @@ async def test_invalid_multilevel_sensor_scale( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) client.driver.controller.receive_event(event) @@ -610,7 +610,7 @@ async def test_invalid_meter_scale( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) client.driver.controller.receive_event(event) @@ -796,12 +796,14 @@ CONTROLLER_STATISTICS_SUFFIXES = { } # controller statistics with initial state of unknown CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN = { - "current_background_rssi_channel_0": -1, - "average_background_rssi_channel_0": -2, - "current_background_rssi_channel_1": -3, - "average_background_rssi_channel_1": -4, - "current_background_rssi_channel_2": STATE_UNKNOWN, - "average_background_rssi_channel_2": STATE_UNKNOWN, + "signal_noise_channel_0": -1, + "avg_signal_noise_channel_0": -2, + "signal_noise_channel_1": -3, + "avg_signal_noise_channel_1": -4, + "signal_noise_channel_2": -5, + "avg_signal_noise_channel_2": -6, + "signal_noise_channel_3": STATE_UNKNOWN, + "avg_signal_noise_channel_3": STATE_UNKNOWN, } NODE_STATISTICS_ENTITY_PREFIX = "sensor.4_in_1_sensor_" # node statistics with initial state of 0 @@ -815,7 +817,7 @@ NODE_STATISTICS_SUFFIXES = { # node statistics with initial state of unknown NODE_STATISTICS_SUFFIXES_UNKNOWN = { "round_trip_time": 6, - "rssi": 7, + "signal_strength": 7, } @@ -867,7 +869,7 @@ async def test_statistics_sensors_migration( ) -async def test_statistics_sensors_no_last_seen( +async def test_statistics_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, @@ -875,7 +877,7 @@ async def test_statistics_sensors_no_last_seen( integration, caplog: pytest.LogCaptureFixture, ) -> None: - """Test all statistics sensors but last seen which is enabled by default.""" + """Test statistics sensors.""" for prefix, suffixes in ( (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), @@ -885,7 +887,7 @@ async def test_statistics_sensors_no_last_seen( ): for suffix_key in suffixes: entry = entity_registry.async_get(f"{prefix}{suffix_key}") - assert entry + assert entry, f"Entity {prefix}{suffix_key} not found" assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @@ -911,12 +913,12 @@ async def test_statistics_sensors_no_last_seen( ): for suffix_key in suffixes: entry = entity_registry.async_get(f"{prefix}{suffix_key}") - assert entry + assert entry, f"Entity {prefix}{suffix_key} not found" assert not entry.disabled assert entry.disabled_by is None state = hass.states.get(entry.entity_id) - assert state + assert state, f"State for {entry.entity_id} not found" assert state.state == initial_state # Fire statistics updated for controller @@ -944,6 +946,10 @@ async def test_statistics_sensors_no_last_seen( "current": -3, "average": -4, }, + "channel2": { + "current": -5, + "average": -6, + }, "timestamp": 1681967176510, }, }, @@ -1023,7 +1029,16 @@ async def test_last_seen_statistics_sensors( entity_id = f"{NODE_STATISTICS_ENTITY_PREFIX}last_seen" entry = entity_registry.async_get(entity_id) assert entry - assert not entry.disabled + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + + entity_registry.async_update_entity(entity_id, disabled_by=None) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index c875522b943..32cf3edf010 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4969,11 +4969,9 @@ async def test_async_track_state_report_change_event(hass: HomeAssistant) -> Non hass.states.async_set(entity_id, state) await hass.async_block_till_done() - # The out-of-order is a result of state change listeners scheduled with - # loop.call_soon, whereas state report listeners are called immediately. assert tracker_called == { - "light.bowl": ["on", "off", "on", "off"], - "light.top": ["on", "off", "on", "off"], + "light.bowl": ["on", "on", "off", "off"], + "light.top": ["on", "on", "off", "off"], } diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 0e68992d0e4..50d9da501c5 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -88,7 +88,6 @@ def _test_selector( ({"integration": "zha"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf"}, ("abc123",), (None,)), ({"model": "mock-model"}, ("abc123",), (None,)), - ({"model_id": "mock-model_id"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf", "model": "mock-model"}, ("abc123",), (None,)), ( {"integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model"}, @@ -128,6 +127,7 @@ def _test_selector( "integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model", + "model_id": "mock-model_id", } }, ("abc123",), @@ -140,11 +140,13 @@ def _test_selector( "integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model", + "model_id": "mock-model_id", }, { "integration": "matter", "manufacturer": "other-mock-manuf", "model": "other-mock-model", + "model_id": "other-mock-model_id", }, ] }, @@ -158,6 +160,19 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("device", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + "schema", + [ + # model_id should be used under the filter key + {"model_id": "mock-model_id"}, + ], +) +def test_device_selector_schema_error(schema) -> None: + """Test device selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"device": schema}) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), [ @@ -216,6 +231,11 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> ["sensor.abc123", "sensor.ghi789"], ), ), + ( + {"multiple": True, "reorder": True}, + ((["sensor.abc123", "sensor.def456"],)), + (None, "abc123", ["sensor.abc123", None]), + ), ( {"filter": {"domain": "light"}}, ("light.abc123", FAKE_UUID), @@ -290,10 +310,12 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> {"filter": [{"supported_features": ["light.FooEntityFeature.blah"]}]}, # Unknown feature enum member {"filter": [{"supported_features": ["light.LightEntityFeature.blah"]}]}, + # supported_features should be used under the filter key + {"supported_features": ["light.LightEntityFeature.EFFECT"]}, ], ) def test_entity_selector_schema_error(schema) -> None: - """Test number selector.""" + """Test entity selector.""" with pytest.raises(vol.Invalid): selector.validate_selector({"entity": schema}) @@ -410,6 +432,7 @@ def test_assist_pipeline_selector_schema( ({"mode": "box"}, (10,), ()), ({"mode": "box", "step": "any"}, (), ()), ({"mode": "slider", "min": 0, "max": 1, "step": "any"}, (), ()), + ({}, (), ()), ], ) def test_number_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -417,10 +440,28 @@ def test_number_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("number", schema, valid_selections, invalid_selections) +def test_number_selector_schema_default_mode() -> None: + """Test number selector default mode set on min/max.""" + assert selector.selector({"number": {"min": 10, "max": 50}}).config == { + "mode": "slider", + "min": 10.0, + "max": 50.0, + "step": 1.0, + } + assert selector.selector({"number": {}}).config == { + "mode": "box", + "step": 1.0, + } + assert selector.selector({"number": {"min": "10"}}).config == { + "mode": "box", + "min": 10.0, + "step": 1.0, + } + + @pytest.mark.parametrize( "schema", [ - {}, # Must have mandatory fields {"mode": "slider"}, # Must have min+max in slider mode ], ) @@ -524,6 +565,11 @@ def test_time_selector_schema(schema, valid_selections, invalid_selections) -> N ("on", "armed"), (None, True, 1), ), + ( + {"hide_states": ["unknown", "unavailable"]}, + (), + (), + ), ], ) def test_state_selector_schema(schema, valid_selections, invalid_selections) -> None: diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 0191827cd58..8f094536988 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -987,7 +987,7 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: "test_domain": { "test_service": { "description": "", - "fields": {"test": {"selector": {"text": None}}}, + "fields": {"test": {"selector": {"text": {}}}}, "name": "", } } @@ -1013,6 +1013,13 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: - light.ColorMode.COLOR_TEMP selector: number: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME advanced_stuff: fields: temperature: @@ -1024,6 +1031,13 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: - light.ColorMode.COLOR_TEMP selector: number: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME """ domain = "test_domain" @@ -1065,7 +1079,21 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: "attribute": {"supported_color_modes": ["color_temp"]}, "supported_features": [1], }, - "selector": {"number": None}, + "selector": {"number": {}}, + }, + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + "reorder": False, + }, + }, }, }, }, @@ -1074,7 +1102,21 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: "attribute": {"supported_color_modes": ["color_temp"]}, "supported_features": [1], }, - "selector": {"number": None}, + "selector": {"number": {}}, + }, + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + "reorder": False, + }, + }, }, }, "name": "", diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index 8389218054d..fcfdd249d75 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -4,7 +4,10 @@ from typing import Any import pytest +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_ICON, CONF_NAME, CONF_STATE, @@ -20,6 +23,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerEntity, + ManualTriggerSensorEntity, ValueTemplate, ) @@ -288,3 +292,38 @@ async def test_trigger_template_complex(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entity.some_other_key == {"test_key": "test_data"} + + +async def test_manual_trigger_sensor_entity_with_date( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability template isn't used.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_STATE: template.Template("{{ as_datetime(value) }}", hass), + CONF_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, + } + + class TestEntity(ManualTriggerSensorEntity): + """Test entity class.""" + + extra_template_keys = (CONF_STATE,) + + @property + def state(self) -> bool | None: + """Return extra attributes.""" + return "2025-01-01T00:00:00+00:00" + + entity = TestEntity(hass, config) + entity.entity_id = "test.entity" + variables = entity._template_variables_with_value("2025-01-01T00:00:00+00:00") + assert entity._render_availability_template(variables) is True + assert entity.available is True + entity._set_native_value_with_possible_timestamp(entity.state) + await hass.async_block_till_done() + + assert entity.native_value == async_parse_date_datetime( + "2025-01-01T00:00:00+00:00", entity.entity_id, entity.device_class + ) + assert entity.state == "2025-01-01T00:00:00+00:00" + assert entity.device_class == SensorDeviceClass.TIMESTAMP diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 5fd9f9e39fd..b4216a3fc6d 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -19,7 +19,7 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import update_coordinator +from homeassistant.helpers import frame, update_coordinator from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -165,8 +165,6 @@ async def test_shutdown_on_entry_unload( ) -> None: """Test shutdown is requested on entry unload.""" entry = MockConfigEntry() - config_entries.current_entry.set(entry) - calls = 0 async def _refresh() -> int: @@ -177,6 +175,7 @@ async def test_shutdown_on_entry_unload( crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=entry, name="test", update_method=_refresh, update_interval=DEFAULT_UPDATE_INTERVAL, @@ -206,6 +205,7 @@ async def test_shutdown_on_hass_stop( crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=None, name="test", update_method=_refresh, update_interval=DEFAULT_UPDATE_INTERVAL, @@ -843,6 +843,7 @@ async def test_timestamp_date_update_coordinator(hass: HomeAssistant) -> None: crd = update_coordinator.TimestampDataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=None, name="test", update_method=refresh, update_interval=timedelta(seconds=10), @@ -865,39 +866,133 @@ async def test_timestamp_date_update_coordinator(hass: HomeAssistant) -> None: assert len(last_update_success_times) == 1 -async def test_config_entry(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "integration_frame_path", ["homeassistant/components/my_integration"] +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_config_entry( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: """Test behavior of coordinator.entry.""" entry = MockConfigEntry() - # Default without context should be None - crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") - assert crd.config_entry is None - # Explicit None is OK crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=None ) assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) # Explicit entry is OK + caplog.clear() crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=entry ) assert crd.config_entry is entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit entry different from ContextVar not recommended, but should work + another_entry = MockConfigEntry() + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=another_entry + ) + assert crd.config_entry is another_entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Default without context should log a warning + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ) in caplog.text + + # Default with context should log a warning + caplog.clear() + frame._REPORTED_INTEGRATIONS.clear() + config_entries.current_entry.set(entry) + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert ( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ) in caplog.text + assert crd.config_entry is entry + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("hass", "mock_integration_frame") +async def test_config_entry_custom_integration( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test behavior of coordinator.entry for custom integrations.""" + entry = MockConfigEntry(domain="custom_integration") + + # Default without context should be None + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit None is OK + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=None + ) + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit entry is OK + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=entry + ) + assert crd.config_entry is entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) # set ContextVar config_entries.current_entry.set(entry) # Default with ContextVar should match the ContextVar + caplog.clear() crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") assert crd.config_entry is entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) # Explicit entry different from ContextVar not recommended, but should work another_entry = MockConfigEntry() + caplog.clear() crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=another_entry ) assert crd.config_entry is another_entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: @@ -920,7 +1015,7 @@ async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> self._unsub = None coordinator = update_coordinator.DataUpdateCoordinator[int]( - hass, _LOGGER, name="test" + hass, _LOGGER, config_entry=None, name="test" ) subscriber = Subscriber() subscriber.start_listen(coordinator) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index dc893e4c5fd..9666e8ba1c4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -15,6 +15,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant import config_entries, data_entry_flow, loader from homeassistant.config_entries import ConfigEntry @@ -4901,6 +4902,7 @@ async def test_setup_raise_entry_error_from_first_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -4941,6 +4943,7 @@ async def test_setup_not_raise_entry_error_from_future_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -5020,6 +5023,7 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -5072,6 +5076,7 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -8652,6 +8657,95 @@ async def test_options_flow_config_entry( assert result["reason"] == "abort" +@pytest.mark.parametrize( + ( + "option_flow_base_class", + "number_of_update_listeners", + "expected_configure_result", + "expected_number_of_unloads", + ), + [ + (config_entries.OptionsFlow, 0, does_not_raise(), 0), + (config_entries.OptionsFlowWithReload, 0, does_not_raise(), 1), + (config_entries.OptionsFlow, 1, does_not_raise(), 0), + ( + config_entries.OptionsFlowWithReload, + 1, + pytest.raises( + ValueError, + match="Config entry update listeners should not be used with OptionsFlowWithReload", + ), + 0, + ), + ], +) +async def test_options_flow_automatic_reload( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + option_flow_base_class: type[config_entries.OptionsFlow], + number_of_update_listeners: int, + expected_configure_result: AbstractContextManager, + expected_number_of_unloads: int, +) -> None: + """Test options flow with automatic reload when updated.""" + original_entry = MockConfigEntry( + domain="test", title="Test", data={}, options={"test": "first"} + ) + original_entry.add_to_hass(hass) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Mock setup entry.""" + for _ in range(number_of_update_listeners): + entry.add_update_listener(Mock()) + return True + + unload_entry_mock = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry, + async_unload_entry=unload_entry_mock, + ), + ) + mock_platform(hass, "test.config_flow", None) + + await hass.config_entries.async_setup(original_entry.entry_id) + assert original_entry.state is config_entries.ConfigEntryState.LOADED + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + class _OptionsFlow(option_flow_base_class): + """Test flow.""" + + async def async_step_init(self, user_input=None): + """Test user step.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + return self.async_show_form( + step_id="init", data_schema=vol.Schema({"test": str}) + ) + + return _OptionsFlow() + + with mock_config_flow("test", TestFlow): + result = await hass.config_entries.options.async_init(original_entry.entry_id) + with expected_configure_result: + await hass.config_entries.options.async_configure( + result["flow_id"], {"test": "updated"} + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(unload_entry_mock.mock_calls) == expected_number_of_unloads + + @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") async def test_options_flow_deprecated_config_entry_setter(