diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 81a04bfb4c680c..61f626fe22c3e3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -741,7 +741,7 @@ jobs: - name: Generate partial mypy restore key id: generate-mypy-key run: | - mypy_version=$(cat requirements_test.txt | grep mypy | cut -d '=' -f 3) + mypy_version=$(cat requirements_test.txt | grep 'mypy.*=' | cut -d '=' -f 3) echo "version=$mypy_version" >> $GITHUB_OUTPUT echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-${{ env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 14ee68037320a1..80ba6a0c8d38b1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 + uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 + uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 with: category: "/language:python" diff --git a/CODEOWNERS b/CODEOWNERS index f518040f55ba70..cae17682516680 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1413,8 +1413,8 @@ build.json @home-assistant/supervisor /tests/components/sfr_box/ @epenet /homeassistant/components/sftp_storage/ @maretodoric /tests/components/sftp_storage/ @maretodoric -/homeassistant/components/sharkiq/ @JeffResc @funkybunch -/tests/components/sharkiq/ @JeffResc @funkybunch +/homeassistant/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre +/tests/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre /homeassistant/components/shell_command/ @home-assistant/core /tests/components/shell_command/ @home-assistant/core /homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco diff --git a/homeassistant/components/aprilaire/climate.py b/homeassistant/components/aprilaire/climate.py index 5116e06b58fb12..bc8226c65a6c97 100644 --- a/homeassistant/components/aprilaire/climate.py +++ b/homeassistant/components/aprilaire/climate.py @@ -7,6 +7,8 @@ from pyaprilaire.const import Attribute from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, FAN_AUTO, FAN_ON, PRESET_AWAY, @@ -16,7 +18,12 @@ HVACAction, HVACMode, ) -from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_WHOLE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -232,15 +239,15 @@ async def async_set_temperature(self, **kwargs: Any) -> None: cool_setpoint = 0 heat_setpoint = 0 - if temperature := kwargs.get("temperature"): + if temperature := kwargs.get(ATTR_TEMPERATURE): if self.coordinator.data.get(Attribute.MODE) == 3: cool_setpoint = temperature else: heat_setpoint = temperature else: - if target_temp_low := kwargs.get("target_temp_low"): + if target_temp_low := kwargs.get(ATTR_TARGET_TEMP_LOW): heat_setpoint = target_temp_low - if target_temp_high := kwargs.get("target_temp_high"): + if target_temp_high := kwargs.get(ATTR_TARGET_TEMP_HIGH): cool_setpoint = target_temp_high if cool_setpoint == 0 and heat_setpoint == 0: diff --git a/homeassistant/components/bryant_evolution/climate.py b/homeassistant/components/bryant_evolution/climate.py index bd053229a1a0fd..a2e89661afbceb 100644 --- a/homeassistant/components/bryant_evolution/climate.py +++ b/homeassistant/components/bryant_evolution/climate.py @@ -7,12 +7,14 @@ from evolutionhttp import BryantEvolutionLocalClient from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, ClimateEntity, ClimateEntityFeature, HVACAction, HVACMode, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -208,24 +210,24 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if kwargs.get("target_temp_high"): - temp = int(kwargs["target_temp_high"]) + if value := kwargs.get(ATTR_TARGET_TEMP_HIGH): + temp = int(value) if not await self._client.set_cooling_setpoint(temp): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="failed_to_set_clsp" ) self._attr_target_temperature_high = temp - if kwargs.get("target_temp_low"): - temp = int(kwargs["target_temp_low"]) + if value := kwargs.get(ATTR_TARGET_TEMP_LOW): + temp = int(value) if not await self._client.set_heating_setpoint(temp): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="failed_to_set_htsp" ) self._attr_target_temperature_low = temp - if kwargs.get("temperature"): - temp = int(kwargs["temperature"]) + if value := kwargs.get(ATTR_TEMPERATURE): + temp = int(value) fn = ( self._client.set_heating_setpoint if self.hvac_mode == HVACMode.HEAT diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index a6a6e447426d66..7dcfadd9ffe74d 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.11.2"] + "requirements": ["env-canada==0.11.3"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9b38b83f335f3b..adc0ffab70b0e1 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==41.12.0", + "aioesphomeapi==41.13.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.4.0" ], diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 40439c1eb02fc5..7607d3ead9eb93 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -29,7 +29,12 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature +from homeassistant.const import ( + ATTR_MODE, + ATTR_TEMPERATURE, + PRECISION_TENTHS, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -243,7 +248,7 @@ def max_temp(self) -> float: async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" - temperature = kwargs["temperature"] + temperature = kwargs[ATTR_TEMPERATURE] if (until := kwargs.get("until")) is None: if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE: diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 681ebcbbef7089..b9840fb2b68b12 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -456,7 +456,7 @@ def available(self) -> bool: return self._available @ha_callback - @pyhap_callback # type: ignore[misc] + @pyhap_callback # type: ignore[untyped-decorator] def run(self) -> None: """Handle accessory driver started event.""" if state := self.hass.states.get(self.entity_id): @@ -725,7 +725,7 @@ def __init__( self._entry_title = entry_title self.iid_storage = iid_storage - @pyhap_callback # type: ignore[misc] + @pyhap_callback # type: ignore[untyped-decorator] def pair( self, client_username_bytes: bytes, client_public: str, client_permissions: int ) -> bool: @@ -735,7 +735,7 @@ def pair( async_dismiss_setup_message(self.hass, self.entry_id) return cast(bool, success) - @pyhap_callback # type: ignore[misc] + @pyhap_callback # type: ignore[untyped-decorator] def unpair(self, client_uuid: UUID) -> None: """Override super function to show setup message if unpaired.""" super().unpair(client_uuid) diff --git a/homeassistant/components/homekit/doorbell.py b/homeassistant/components/homekit/doorbell.py index 45bbb2ea0ca758..9857cf83b364c0 100644 --- a/homeassistant/components/homekit/doorbell.py +++ b/homeassistant/components/homekit/doorbell.py @@ -71,7 +71,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.async_update_doorbell_state(None, state) @ha_callback - @pyhap_callback # type: ignore[misc] + @pyhap_callback # type: ignore[untyped-decorator] def run(self) -> None: """Handle doorbell event.""" if self._char_doorbell_detected: diff --git a/homeassistant/components/homekit/type_air_purifiers.py b/homeassistant/components/homekit/type_air_purifiers.py index feb75f4a856a04..62e07d3a25b0fe 100644 --- a/homeassistant/components/homekit/type_air_purifiers.py +++ b/homeassistant/components/homekit/type_air_purifiers.py @@ -219,7 +219,7 @@ def should_add_preset_mode_switch(self, preset_mode: str) -> bool: return preset_mode.lower() != "auto" @callback - @pyhap_callback # type: ignore[misc] + @pyhap_callback # type: ignore[untyped-decorator] def run(self) -> None: """Handle accessory driver started event. diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 0fb2c2e792264b..cb5de0265014f1 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -229,7 +229,7 @@ def __init__( ) self._async_update_motion_state(None, state) - @pyhap_callback # type: ignore[misc] + @pyhap_callback # type: ignore[untyped-decorator] @callback def run(self) -> None: """Handle accessory driver started event. diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 651033682cf31e..13b17d6f3ddfc5 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -127,7 +127,7 @@ def __init__(self, *args: Any) -> None: self.async_update_state(state) @callback - @pyhap_callback # type: ignore[misc] + @pyhap_callback # type: ignore[untyped-decorator] def run(self) -> None: """Handle accessory driver started event. diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index a57a5e00974942..2cdd031cbfae7a 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -178,7 +178,7 @@ def __init__(self, *args: Any) -> None: self._async_update_current_humidity(humidity_state) @callback - @pyhap_callback # type: ignore[misc] + @pyhap_callback # type: ignore[untyped-decorator] def run(self) -> None: """Handle accessory driver started event. diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index 44db65d7b0bf10..86b2019e97e892 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -108,7 +108,7 @@ async def async_attach(self) -> None: _LOGGER.log, ) - @pyhap_callback # type: ignore[misc] + @pyhap_callback # type: ignore[untyped-decorator] @callback def run(self) -> None: """Run the accessory.""" diff --git a/homeassistant/components/london_underground/__init__.py b/homeassistant/components/london_underground/__init__.py index b38aba6dbc3480..c9910ee846108e 100644 --- a/homeassistant/components/london_underground/__init__.py +++ b/homeassistant/components/london_underground/__init__.py @@ -1 +1,36 @@ """The london_underground component.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN as DOMAIN +from .coordinator import LondonTubeCoordinator, LondonUndergroundConfigEntry, TubeData + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: LondonUndergroundConfigEntry +) -> bool: + """Set up London Underground from a config entry.""" + + session = async_get_clientsession(hass) + data = TubeData(session) + coordinator = LondonTubeCoordinator(hass, data, config_entry=entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + # Forward the setup to the sensor platform + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: LondonUndergroundConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/london_underground/config_flow.py b/homeassistant/components/london_underground/config_flow.py new file mode 100644 index 00000000000000..baca9b91c32bc4 --- /dev/null +++ b/homeassistant/components/london_underground/config_flow.py @@ -0,0 +1,152 @@ +"""Config flow for London Underground integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from london_tube_status import TubeData +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) +from homeassistant.core import callback +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_LINE, DEFAULT_LINES, DOMAIN, TUBE_LINES + +_LOGGER = logging.getLogger(__name__) + + +class LondonUndergroundConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for London Underground.""" + + VERSION = 1 + MINOR_VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + _: ConfigEntry, + ) -> LondonUndergroundOptionsFlow: + """Get the options flow for this handler.""" + return LondonUndergroundOptionsFlow() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + session = async_get_clientsession(self.hass) + data = TubeData(session) + try: + async with asyncio.timeout(10): + await data.update() + except TimeoutError: + errors["base"] = "timeout_connect" + except Exception: + _LOGGER.exception("Unexpected error") + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="London Underground", + data={}, + options={CONF_LINE: user_input.get(CONF_LINE, DEFAULT_LINES)}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_LINE, + default=DEFAULT_LINES, + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=TUBE_LINES, + multiple=True, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) + + async def async_step_import(self, import_data: ConfigType) -> ConfigFlowResult: + """Handle import from configuration.yaml.""" + session = async_get_clientsession(self.hass) + data = TubeData(session) + try: + async with asyncio.timeout(10): + await data.update() + except Exception: + _LOGGER.exception( + "Unexpected error trying to connect before importing config, aborting import " + ) + return self.async_abort(reason="cannot_connect") + + _LOGGER.warning( + "Importing London Underground config from configuration.yaml: %s", + import_data, + ) + # Extract lines from the sensor platform config + lines = import_data.get(CONF_LINE, DEFAULT_LINES) + if "London Overground" in lines: + _LOGGER.warning( + "London Overground was removed from the configuration as the line has been divided and renamed" + ) + lines.remove("London Overground") + return self.async_create_entry( + title="London Underground", + data={}, + options={CONF_LINE: import_data.get(CONF_LINE, DEFAULT_LINES)}, + ) + + +class LondonUndergroundOptionsFlow(OptionsFlowWithReload): + """Handle options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + _LOGGER.debug( + "Updating london underground with options flow user_input: %s", + user_input, + ) + return self.async_create_entry( + title="", + data={CONF_LINE: user_input[CONF_LINE]}, + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_LINE, + default=self.config_entry.options.get( + CONF_LINE, + self.config_entry.data.get(CONF_LINE, DEFAULT_LINES), + ), + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=TUBE_LINES, + multiple=True, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + ) diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py index 447ed4461f3a77..9c96ff1ece04bb 100644 --- a/homeassistant/components/london_underground/const.py +++ b/homeassistant/components/london_underground/const.py @@ -6,7 +6,6 @@ CONF_LINE = "line" - SCAN_INTERVAL = timedelta(seconds=30) TUBE_LINES = [ @@ -18,7 +17,7 @@ "Elizabeth line", "Hammersmith & City", "Jubilee", - "London Overground", + "London Overground", # no longer supported "Metropolitan", "Northern", "Piccadilly", @@ -31,3 +30,20 @@ "Weaver", "Windrush", ] + +# Default lines to monitor if none selected +DEFAULT_LINES = [ + "Bakerloo", + "Central", + "Circle", + "District", + "DLR", + "Elizabeth line", + "Hammersmith & City", + "Jubilee", + "Metropolitan", + "Northern", + "Piccadilly", + "Victoria", + "Waterloo & City", +] diff --git a/homeassistant/components/london_underground/coordinator.py b/homeassistant/components/london_underground/coordinator.py index 29d1e8e2f54a35..a80150e6313d70 100644 --- a/homeassistant/components/london_underground/coordinator.py +++ b/homeassistant/components/london_underground/coordinator.py @@ -8,6 +8,7 @@ from london_tube_status import TubeData +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -15,16 +16,23 @@ _LOGGER = logging.getLogger(__name__) +type LondonUndergroundConfigEntry = ConfigEntry[LondonTubeCoordinator] + class LondonTubeCoordinator(DataUpdateCoordinator[dict[str, dict[str, str]]]): """London Underground sensor coordinator.""" - def __init__(self, hass: HomeAssistant, data: TubeData) -> None: + def __init__( + self, + hass: HomeAssistant, + data: TubeData, + config_entry: LondonUndergroundConfigEntry, + ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, - config_entry=None, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index 94b993097c0225..1b9b8ddcbeb8e0 100644 --- a/homeassistant/components/london_underground/manifest.json +++ b/homeassistant/components/london_underground/manifest.json @@ -2,9 +2,12 @@ "domain": "london_underground", "name": "London Underground", "codeowners": ["@jpbede"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/london_underground", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["london_tube_status"], "quality_scale": "legacy", - "requirements": ["london-tube-status==0.5"] + "requirements": ["london-tube-status==0.5"], + "single_config_entry": true } diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 645f8f48ae2e6a..c9df10b470c676 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -5,23 +5,26 @@ import logging from typing import Any -from london_tube_status import TubeData import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_LINE, TUBE_LINES -from .coordinator import LondonTubeCoordinator +from .const import CONF_LINE, DOMAIN, TUBE_LINES +from .coordinator import LondonTubeCoordinator, LondonUndergroundConfigEntry _LOGGER = logging.getLogger(__name__) @@ -38,18 +41,54 @@ async def async_setup_platform( ) -> None: """Set up the Tube sensor.""" - session = async_get_clientsession(hass) - - data = TubeData(session) - coordinator = LondonTubeCoordinator(hass, data) + # If configuration.yaml config exists, trigger the import flow. + # If the config entry already exists, this will not be triggered as only one config is allowed. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result.get('reason')}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "London Underground", + }, + ) + return + + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + "deprecated_yaml", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "London Underground", + }, + ) - await coordinator.async_refresh() - if not coordinator.last_update_success: - raise PlatformNotReady +async def async_setup_entry( + hass: HomeAssistant, + entry: LondonUndergroundConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the London Underground sensor from config entry.""" async_add_entities( - LondonTubeSensor(coordinator, line) for line in config[CONF_LINE] + LondonTubeSensor(entry.runtime_data, line) for line in entry.options[CONF_LINE] ) @@ -58,11 +97,21 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): _attr_attribution = "Powered by TfL Open Data" _attr_icon = "mdi:subway" + _attr_has_entity_name = True # Use modern entity naming def __init__(self, coordinator: LondonTubeCoordinator, name: str) -> None: """Initialize the London Underground sensor.""" super().__init__(coordinator) self._name = name + # Add unique_id for proper entity registry + self._attr_unique_id = f"tube_{name.lower().replace(' ', '_')}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, DOMAIN)}, + name="London Underground", + manufacturer="Transport for London", + model="Tube Status", + entry_type=DeviceEntryType.SERVICE, + ) @property def name(self) -> str: diff --git a/homeassistant/components/london_underground/strings.json b/homeassistant/components/london_underground/strings.json new file mode 100644 index 00000000000000..b924d281c04dcd --- /dev/null +++ b/homeassistant/components/london_underground/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up London Underground", + "description": "Select which tube lines you want to monitor", + "data": { + "line": "Tube lines" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "options": { + "step": { + "init": { + "title": "Configure London Underground", + "description": "[%key:component::london_underground::config::step::user::description%]", + "data": { + "line": "[%key:component::london_underground::config::step::user::data::line%]" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue": { + "title": "London Underground YAML configuration deprecated", + "description": "Configuring London Underground using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an error occurred when trying to connect to the Transport for London API. Please restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI." + } + } +} diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 85bcd407fef9e5..fe25c5baa0d507 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -59,7 +59,7 @@ async def get_api_instance() -> llm.APIInstance: # Backwards compatibility with old MCP Server config return await llm.async_get_api(hass, llm_api_id, llm_context) - @server.list_prompts() # type: ignore[no-untyped-call, misc] + @server.list_prompts() # type: ignore[no-untyped-call,untyped-decorator] async def handle_list_prompts() -> list[types.Prompt]: llm_api = await get_api_instance() return [ @@ -69,7 +69,7 @@ async def handle_list_prompts() -> list[types.Prompt]: ) ] - @server.get_prompt() # type: ignore[no-untyped-call, misc] + @server.get_prompt() # type: ignore[no-untyped-call,untyped-decorator] async def handle_get_prompt( name: str, arguments: dict[str, str] | None ) -> types.GetPromptResult: @@ -90,13 +90,13 @@ async def handle_get_prompt( ], ) - @server.list_tools() # type: ignore[no-untyped-call, misc] + @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator] async def list_tools() -> list[types.Tool]: """List available time tools.""" llm_api = await get_api_instance() return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools] - @server.call_tool() # type: ignore[misc] + @server.call_tool() # type: ignore[untyped-decorator] async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]: """Handle calling tools.""" llm_api = await get_api_instance() diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index b5fd57c716dec0..47a96d03f06bee 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -408,5 +408,5 @@ def target_temperature(self) -> float | None: async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" await self._zone.set_target_temperature( - kwargs.get("temperature", self.target_temperature) + kwargs.get(ATTR_TEMPERATURE, self.target_temperature) ) diff --git a/homeassistant/components/mill/coordinator.py b/homeassistant/components/mill/coordinator.py index ea1295376ae465..1991cad213ca54 100644 --- a/homeassistant/components/mill/coordinator.py +++ b/homeassistant/components/mill/coordinator.py @@ -10,7 +10,11 @@ from mill_local import Mill as MillLocal from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -147,7 +151,7 @@ async def _async_update_data(self): ) ) metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{heater.name}", source=DOMAIN, diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 467ccd6d8216cb..5f376806d7c600 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -253,6 +253,7 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: self._client: ( AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None ) = None + self._lock = asyncio.Lock() self.event_connected = asyncio.Event() self.hass = hass self.name = client_config[CONF_NAME] @@ -415,7 +416,9 @@ async def async_pb_call( """Convert async to sync pymodbus call.""" if not self._client: return None - result = await self.low_level_pb_call(unit, address, value, use_call) - if self._msg_wait: - await asyncio.sleep(self._msg_wait) - return result + async with self._lock: + result = await self.low_level_pb_call(unit, address, value, use_call) + if self._msg_wait: + # small delay until next request/response + await asyncio.sleep(self._msg_wait) + return result diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index f395cb2b37d7e5..448efbcc64abb6 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -53,7 +53,7 @@ def __init__( async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - await self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255)) + await self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS)) async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 1193d33d4356c1..07f3aed63e2b9c 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.4.12"] + "requirements": ["nhc==0.6.1"] } diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 0b2fa75b5c07a0..e21005d85415c6 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -316,16 +316,23 @@ async def async_step_model( options = self.options errors: dict[str, str] = {} - step_schema: VolDictType = { - vol.Optional( - CONF_CODE_INTERPRETER, - default=RECOMMENDED_CODE_INTERPRETER, - ): bool, - } + step_schema: VolDictType = {} model = options[CONF_CHAT_MODEL] - if model.startswith(("o", "gpt-5")): + if not model.startswith(("gpt-5-pro", "gpt-5-codex")): + step_schema.update( + { + vol.Optional( + CONF_CODE_INTERPRETER, + default=RECOMMENDED_CODE_INTERPRETER, + ): bool, + } + ) + elif CONF_CODE_INTERPRETER in options: + options.pop(CONF_CODE_INTERPRETER) + + if model.startswith(("o", "gpt-5")) and not model.startswith("gpt-5-pro"): step_schema.update( { vol.Optional( diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index e876c50481d927..0ff6e662918a86 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -468,7 +468,9 @@ async def _async_handle_chat_log( model_args["reasoning"] = { "effort": options.get( CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ), + ) + if not model_args["model"].startswith("gpt-5-pro") + else "high", # GPT-5 pro only supports reasoning.effort: high "summary": "auto", } model_args["include"] = ["reasoning.encrypted_content"] diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index ba78ee324091f0..5925a52a374102 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -18,7 +18,8 @@ from .coordinator import PortainerCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SWITCH] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] + type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index 316851d2c67591..26a6eddc78f431 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -1,5 +1,10 @@ { "entity": { + "sensor": { + "image": { + "default": "mdi:docker" + } + }, "switch": { "container": { "default": "mdi:arrow-down-box", diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py new file mode 100644 index 00000000000000..bc84d26d02a04f --- /dev/null +++ b/homeassistant/components/portainer/sensor.py @@ -0,0 +1,83 @@ +"""Sensor platform for Portainer integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyportainer.models.docker import DockerContainer + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PortainerConfigEntry, PortainerCoordinator +from .entity import PortainerContainerEntity, PortainerCoordinatorData + + +@dataclass(frozen=True, kw_only=True) +class PortainerSensorEntityDescription(SensorEntityDescription): + """Class to hold Portainer sensor description.""" + + value_fn: Callable[[DockerContainer], str | None] + + +CONTAINER_SENSORS: tuple[PortainerSensorEntityDescription, ...] = ( + PortainerSensorEntityDescription( + key="image", + translation_key="image", + value_fn=lambda data: data.image, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PortainerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Portainer sensors based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + PortainerContainerSensor( + coordinator, + entity_description, + container, + endpoint, + ) + for endpoint in coordinator.data.values() + for container in endpoint.containers.values() + for entity_description in CONTAINER_SENSORS + ) + + +class PortainerContainerSensor(PortainerContainerEntity, SensorEntity): + """Representation of a Portainer container sensor.""" + + entity_description: PortainerSensorEntityDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerSensorEntityDescription, + device_info: DockerContainer, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize the Portainer container sensor.""" + self.entity_description = entity_description + super().__init__(device_info, coordinator, via_device) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self.endpoint_id in self.coordinator.data + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.data[self.endpoint_id].containers[self.device_id] + ) diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index e48f8505277a6b..38aa5c87df7e90 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -46,6 +46,11 @@ "name": "Status" } }, + "sensor": { + "image": { + "name": "Image" + } + }, "switch": { "container": { "name": "Container" diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 793f65483ea2b7..e5e1d1b6a8ecc2 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -1,7 +1,7 @@ { "domain": "sharkiq", "name": "Shark IQ", - "codeowners": ["@JeffResc", "@funkybunch"], + "codeowners": ["@JeffResc", "@funkybunch", "@TheOneOgre"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 28d8c2de084a0c..a4f62c0d803af8 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -157,21 +157,18 @@ def __init__( key="input|input", name="Input", device_class=BinarySensorDeviceClass.POWER, - entity_registry_enabled_default=False, removal_condition=is_block_momentary_input, ), ("relay", "input"): BlockBinarySensorDescription( key="relay|input", name="Input", device_class=BinarySensorDeviceClass.POWER, - entity_registry_enabled_default=False, removal_condition=is_block_momentary_input, ), ("device", "input"): BlockBinarySensorDescription( key="device|input", name="Input", device_class=BinarySensorDeviceClass.POWER, - entity_registry_enabled_default=False, removal_condition=is_block_momentary_input, ), ("sensor", "extInput"): BlockBinarySensorDescription( @@ -201,7 +198,6 @@ def __init__( key="input", sub_key="state", device_class=BinarySensorDeviceClass.POWER, - entity_registry_enabled_default=False, removal_condition=is_rpc_momentary_input, ), "cloud": RpcBinarySensorDescription( diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index f2ef3ce906344e..8e964e0c7769f8 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -100,8 +100,9 @@ ATTR_TIMESTAMP = "timestamp" DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) -DEFAULT_SOCKET_MIN_RETRY = 15 +WEBSOCKET_RECONNECT_RETRIES = 3 +WEBSOCKET_RETRY_DELAY = 2 EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT" EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION" @@ -419,6 +420,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: API) -> None: self._api = api self._hass = hass self._system_notifications: dict[int, set[SystemNotification]] = {} + self._websocket_reconnect_retries: int = 0 self._websocket_reconnect_task: asyncio.Task | None = None self.entry = entry self.initial_event_to_use: dict[int, dict[str, Any]] = {} @@ -469,6 +471,8 @@ async def _async_start_websocket_loop(self) -> None: """Start a websocket reconnection loop.""" assert self._api.websocket + self._websocket_reconnect_retries += 1 + try: await self._api.websocket.async_connect() await self._api.websocket.async_listen() @@ -479,9 +483,21 @@ async def _async_start_websocket_loop(self) -> None: LOGGER.error("Failed to connect to websocket: %s", err) except Exception as err: # noqa: BLE001 LOGGER.error("Unknown exception while connecting to websocket: %s", err) + else: + self._websocket_reconnect_retries = 0 - LOGGER.debug("Reconnecting to websocket") - await self._async_cancel_websocket_loop() + if self._websocket_reconnect_retries >= WEBSOCKET_RECONNECT_RETRIES: + LOGGER.error("Max websocket connection retries exceeded") + return + + delay = WEBSOCKET_RETRY_DELAY * (2 ** (self._websocket_reconnect_retries - 1)) + LOGGER.info( + "Retrying websocket connection in %s seconds (attempt %s/%s)", + delay, + self._websocket_reconnect_retries, + WEBSOCKET_RECONNECT_RETRIES, + ) + await asyncio.sleep(delay) self._websocket_reconnect_task = self._hass.async_create_task( self._async_start_websocket_loop() ) diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index 83283ae8ec51e8..55f1e4955c228b 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -241,7 +241,6 @@ def _get_statistics_metadata( ) -> StatisticMetaData: """Build statistics metadata for requested configuration.""" return StatisticMetaData( - has_mean=False, mean_type=StatisticMeanType.NONE, has_sum=True, name=f"Suez water {name} {self._counter_id}", diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 9302746aa178cb..84e696de42e9d1 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.0.0"], + "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.1.0"], "single_config_entry": true } diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index ab1d8db16fa529..fe11d2668ef882 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -18,7 +18,7 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -352,7 +352,7 @@ def set_temperature(self, **kwargs: Any) -> None: { "code": self._set_temperature.dpcode, "value": round( - self._set_temperature.scale_value_back(kwargs["temperature"]) + self._set_temperature.scale_value_back(kwargs[ATTR_TEMPERATURE]) ), } ] diff --git a/homeassistant/components/volvo/diagnostics.py b/homeassistant/components/volvo/diagnostics.py new file mode 100644 index 00000000000000..368667e039eaeb --- /dev/null +++ b/homeassistant/components/volvo/diagnostics.py @@ -0,0 +1,45 @@ +"""Volvo diagnostics.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.redact import async_redact_data + +from .const import CONF_VIN +from .coordinator import VolvoConfigEntry + +_TO_REDACT_ENTRY = [ + CONF_ACCESS_TOKEN, + CONF_API_KEY, + CONF_VIN, + "id_token", + "refresh_token", +] + +_TO_REDACT_DATA = [ + "coordinates", + "heading", + "vin", +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: VolvoConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + context = entry.runtime_data.interval_coordinators[0].context + data: dict[str, dict] = {} + + for coordinator in entry.runtime_data.interval_coordinators: + data[coordinator.name] = { + key: async_redact_data(asdict(value), _TO_REDACT_DATA) if value else None + for key, value in coordinator.data.items() + } + + return { + "entry_data": async_redact_data(entry.data, _TO_REDACT_ENTRY), + "vehicle": async_redact_data(asdict(context.vehicle), _TO_REDACT_DATA), + **data, + } diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index e3339661d15938..fea90b5167fa86 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -11,7 +11,13 @@ from propcache.api import cached_property from zha.mixins import LogMixin -from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, EntityCategory +from homeassistant.const import ( + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_VIA_DEVICE, + EntityCategory, +) from homeassistant.core import State, callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -85,14 +91,19 @@ def device_info(self) -> DeviceInfo: ieee = zha_device_info["ieee"] zha_gateway = self.entity_data.device_proxy.gateway_proxy.gateway - return DeviceInfo( + device_info = DeviceInfo( connections={(CONNECTION_ZIGBEE, ieee)}, identifiers={(DOMAIN, ieee)}, manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)), ) + if ieee != str(zha_gateway.state.node_info.ieee): + device_info[ATTR_VIA_DEVICE] = ( + DOMAIN, + str(zha_gateway.state.node_info.ieee), + ) + return device_info @callback def _handle_entity_events(self, event: Any) -> None: diff --git a/homeassistant/const.py b/homeassistant/const.py index 1f0bc7bdc30f28..f5d6dd5b4a9c68 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -9,7 +9,6 @@ from .generated.entity_platforms import EntityPlatforms from .helpers.deprecation import ( DeprecatedConstantEnum, - EnumWithDeprecatedMembers, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, @@ -704,35 +703,13 @@ class UnitOfMass(StrEnum): STONES = "st" -class UnitOfConductivity( - StrEnum, - metaclass=EnumWithDeprecatedMembers, - deprecated={ - "SIEMENS": ("UnitOfConductivity.SIEMENS_PER_CM", "2025.11.0"), - "MICROSIEMENS": ("UnitOfConductivity.MICROSIEMENS_PER_CM", "2025.11.0"), - "MILLISIEMENS": ("UnitOfConductivity.MILLISIEMENS_PER_CM", "2025.11.0"), - }, -): +class UnitOfConductivity(StrEnum): """Conductivity units.""" SIEMENS_PER_CM = "S/cm" MICROSIEMENS_PER_CM = "μS/cm" MILLISIEMENS_PER_CM = "mS/cm" - # Deprecated aliases - SIEMENS = "S/cm" - """Deprecated: Please use UnitOfConductivity.SIEMENS_PER_CM""" - MICROSIEMENS = "μS/cm" - """Deprecated: Please use UnitOfConductivity.MICROSIEMENS_PER_CM""" - MILLISIEMENS = "mS/cm" - """Deprecated: Please use UnitOfConductivity.MILLISIEMENS_PER_CM""" - - -_DEPRECATED_CONDUCTIVITY: Final = DeprecatedConstantEnum( - UnitOfConductivity.MICROSIEMENS_PER_CM, - "2025.11", -) -"""Deprecated: please use UnitOfConductivity.MICROSIEMENS_PER_CM""" # Light units LIGHT_LUX: Final = "lx" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fad6bf969391f5..3127aa4e661865 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -367,6 +367,7 @@ "local_ip", "local_todo", "locative", + "london_underground", "lookin", "loqed", "luftdaten", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3cf28545b785f7..821a5892cfc156 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3688,9 +3688,10 @@ }, "london_underground": { "name": "London Underground", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "single_config_entry": true }, "lookin": { "name": "LOOKin", diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 8cadf4b7d4cc21..03c699168ef563 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -36,7 +36,7 @@ callback, split_entity_id, ) -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe @@ -1004,12 +1004,9 @@ def __init__( if track_template_.template.hass: continue - frame.report_usage( - "calls async_track_template_result with template without hass", - core_behavior=frame.ReportBehavior.LOG, - breaks_in_ha_version="2025.10", + raise HomeAssistantError( + "Calls async_track_template_result with template without hass" ) - track_template_.template.hass = hass self._rate_limit = KeyedRateLimit(hass) self._info: dict[Template, RenderInfo] = {} diff --git a/requirements_all.txt b/requirements_all.txt index f0736dc1429cc4..82ec86670c4cf3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.12.0 +aioesphomeapi==41.13.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -895,7 +895,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.11.2 +env-canada==0.11.3 # homeassistant.components.season ephem==4.1.6 @@ -1545,7 +1545,7 @@ nextcord==3.1.0 nextdns==4.1.0 # homeassistant.components.niko_home_control -nhc==0.4.12 +nhc==0.6.1 # homeassistant.components.nibe_heatpump nibe==2.19.0 @@ -1755,7 +1755,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==7.0.0 +psutil==7.1.0 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 diff --git a/requirements_test.txt b/requirements_test.txt index a7edadc0112826..6b5965879ce8a8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,9 +11,11 @@ astroid==3.3.11 coverage==7.10.6 freezegun==1.5.2 go2rtc-client==0.2.1 +# librt is an internal mypy dependency +librt==0.2.1 license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.19.0a2 +mypy-dev==1.19.0a4 pre-commit==4.2.0 pydantic==2.12.0 pylint==3.3.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b81bb34f56d57..16b7c3075479a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.12.0 +aioesphomeapi==41.13.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -777,7 +777,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.11.2 +env-canada==0.11.3 # homeassistant.components.season ephem==4.1.6 @@ -1328,7 +1328,7 @@ nextcord==3.1.0 nextdns==4.1.0 # homeassistant.components.niko_home_control -nhc==0.4.12 +nhc==0.6.1 # homeassistant.components.nibe_heatpump nibe==2.19.0 @@ -1487,7 +1487,7 @@ prowlpy==1.0.2 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==7.0.0 +psutil==7.1.0 # homeassistant.components.pushbullet pushbullet.py==0.11.0 diff --git a/tests/components/london_underground/conftest.py b/tests/components/london_underground/conftest.py new file mode 100644 index 00000000000000..9999133d3e003b --- /dev/null +++ b/tests/components/london_underground/conftest.py @@ -0,0 +1,65 @@ +"""Fixtures for the london_underground tests.""" + +from collections.abc import AsyncGenerator +import json +from unittest.mock import AsyncMock, patch + +from london_tube_status import parse_api_response +import pytest + +from homeassistant.components.london_underground.const import CONF_LINE, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_load_fixture +from tests.conftest import AiohttpClientMocker + + +@pytest.fixture +def mock_setup_entry(): + """Prevent setup of integration during tests.""" + with patch( + "homeassistant.components.london_underground.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock the config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={CONF_LINE: ["Metropolitan"]}, + title="London Underground", + ) + # Add and set up the entry + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + return entry + + +@pytest.fixture +async def mock_london_underground_client( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> AsyncGenerator[AsyncMock]: + """Mock a London Underground client.""" + with ( + patch( + "homeassistant.components.london_underground.TubeData", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.london_underground.config_flow.TubeData", + new=mock_client, + ), + ): + client = mock_client.return_value + + # Load the fixture text + fixture_text = await async_load_fixture(hass, "line_status.json", DOMAIN) + fixture_data = parse_api_response(json.loads(fixture_text)) + client.data = fixture_data + + yield client diff --git a/tests/components/london_underground/test_config_flow.py b/tests/components/london_underground/test_config_flow.py new file mode 100644 index 00000000000000..72324d51c8a8fa --- /dev/null +++ b/tests/components/london_underground/test_config_flow.py @@ -0,0 +1,186 @@ +"""Test the London Underground config flow.""" + +import asyncio + +import pytest + +from homeassistant.components.london_underground.const import ( + CONF_LINE, + DEFAULT_LINES, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir + + +async def test_validate_input_success( + hass: HomeAssistant, mock_setup_entry, mock_london_underground_client +) -> None: + """Test successful validation of TfL API.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: ["Bakerloo", "Central"]}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "London Underground" + assert result["data"] == {} + assert result["options"] == {CONF_LINE: ["Bakerloo", "Central"]} + + +async def test_options( + hass: HomeAssistant, mock_setup_entry, mock_config_entry +) -> None: + """Test updating options.""" + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_LINE: ["Bakerloo", "Central"], + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_LINE: ["Bakerloo", "Central"], + } + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (Exception, "cannot_connect"), + (asyncio.TimeoutError, "timeout_connect"), + ], +) +async def test_validate_input_exceptions( + hass: HomeAssistant, + mock_setup_entry, + mock_london_underground_client, + side_effect, + expected_error, +) -> None: + """Test validation with connection and timeout errors.""" + + mock_london_underground_client.update.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: ["Bakerloo", "Central"]}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == expected_error + + # confirm recovery after error + mock_london_underground_client.update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "London Underground" + assert result["data"] == {} + assert result["options"] == {CONF_LINE: DEFAULT_LINES} + + +async def test_already_configured( + hass: HomeAssistant, + mock_london_underground_client, + mock_setup_entry, + mock_config_entry, +) -> None: + """Try (and fail) setting up a config entry when one already exists.""" + + # Try to start the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_yaml_import( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a YAML sensor is imported and becomes an operational config entry.""" + # Set up via YAML which will trigger import and set up the config entry + IMPORT_DATA = { + "platform": "london_underground", + "line": ["Central", "Piccadilly", "Victoria", "Bakerloo", "Northern"], + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=IMPORT_DATA + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "London Underground" + assert result["data"] == {} + assert result["options"] == { + CONF_LINE: ["Central", "Piccadilly", "Victoria", "Bakerloo", "Northern"] + } + + +async def test_failed_yaml_import_connection( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a YAML sensor is imported and becomes an operational config entry.""" + # Set up via YAML which will trigger import and set up the config entry + mock_london_underground_client.update.side_effect = asyncio.TimeoutError + IMPORT_DATA = { + "platform": "london_underground", + "line": ["Central", "Piccadilly", "Victoria", "Bakerloo", "Northern"], + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=IMPORT_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_failed_yaml_import_already_configured( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + caplog: pytest.LogCaptureFixture, + mock_config_entry, +) -> None: + """Test a YAML sensor is imported and becomes an operational config entry.""" + # Set up via YAML which will trigger import and set up the config entry + + IMPORT_DATA = { + "platform": "london_underground", + "line": ["Central", "Piccadilly", "Victoria", "Bakerloo", "Northern"], + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=IMPORT_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/london_underground/test_init.py b/tests/components/london_underground/test_init.py new file mode 100644 index 00000000000000..fd3237564e2d04 --- /dev/null +++ b/tests/components/london_underground/test_init.py @@ -0,0 +1,20 @@ +"""Test the London Underground init.""" + +from homeassistant.core import HomeAssistant + + +async def test_reload_entry( + hass: HomeAssistant, mock_london_underground_client, mock_config_entry +) -> None: + """Test reloading the config entry.""" + + # Test reloading with updated options + hass.config_entries.async_update_entry( + mock_config_entry, + data={}, + options={"line": ["Bakerloo", "Central"]}, + ) + await hass.async_block_till_done() + + # Verify that setup was called for each reload + assert len(mock_london_underground_client.mock_calls) > 0 diff --git a/tests/components/london_underground/test_sensor.py b/tests/components/london_underground/test_sensor.py index ccb64401eb5e79..2a31ecad2cf722 100644 --- a/tests/components/london_underground/test_sensor.py +++ b/tests/components/london_underground/test_sensor.py @@ -1,37 +1,130 @@ """The tests for the london_underground platform.""" -from london_tube_status import API_URL +import asyncio + +import pytest from homeassistant.components.london_underground.const import CONF_LINE, DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from tests.common import async_load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker - VALID_CONFIG = { "sensor": {"platform": "london_underground", CONF_LINE: ["Metropolitan"]} } async def test_valid_state( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + mock_config_entry, ) -> None: - """Test for operational london_underground sensor with proper attributes.""" - aioclient_mock.get( - API_URL, - text=await async_load_fixture(hass, "line_status.json", DOMAIN), - ) + """Test operational London Underground sensor using a mock config entry.""" + # Ensure the entry is fully loaded + assert mock_config_entry.state is ConfigEntryState.LOADED + # Confirm that the expected entity exists and is correct + state = hass.states.get("sensor.london_underground_metropolitan") + assert state is not None + assert state.state == "Good Service" + assert state.attributes == { + "Description": "Nothing to report", + "attribution": "Powered by TfL Open Data", + "friendly_name": "London Underground Metropolitan", + "icon": "mdi:subway", + } + + # No YAML warning should be issued, since setup was not via YAML + assert not issue_registry.async_get_issue(DOMAIN, "yaml_deprecated") + + +async def test_yaml_import( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a YAML sensor is imported and becomes an operational config entry.""" + # Set up via YAML which will trigger import and set up the config entry + VALID_CONFIG = { + "sensor": { + "platform": "london_underground", + CONF_LINE: ["Metropolitan", "London Overground"], + } + } assert await async_setup_component(hass, "sensor", VALID_CONFIG) await hass.async_block_till_done() - state = hass.states.get("sensor.metropolitan") + # Verify the config entry was created + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Verify a warning was issued about YAML deprecation + assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, "deprecated_yaml") + + # Check the state after setup completes + state = hass.states.get("sensor.london_underground_metropolitan") assert state assert state.state == "Good Service" assert state.attributes == { "Description": "Nothing to report", "attribution": "Powered by TfL Open Data", - "friendly_name": "Metropolitan", + "friendly_name": "London Underground Metropolitan", "icon": "mdi:subway", } + + # Since being renamed London overground is no longer returned by the API + # So check that we do not import it and that we warn the user + state = hass.states.get("sensor.london_underground_london_overground") + assert not state + assert any( + "London Overground was removed from the configuration as the line has been divided and renamed" + in record.message + for record in caplog.records + ) + + +async def test_failed_yaml_import( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a YAML sensor is imported and becomes an operational config entry.""" + # Set up via YAML which will trigger import and set up the config entry + mock_london_underground_client.update.side_effect = asyncio.TimeoutError + VALID_CONFIG = { + "sensor": {"platform": "london_underground", CONF_LINE: ["Metropolitan"]} + } + assert await async_setup_component(hass, "sensor", VALID_CONFIG) + await hass.async_block_till_done() + + # Verify the config entry was not created + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 0 + + # verify no flows still in progress + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + assert any( + "Unexpected error trying to connect before importing config" in record.message + for record in caplog.records + ) + # Confirm that the import did not happen + assert not any( + "Importing London Underground config from configuration.yaml" in record.message + for record in caplog.records + ) + + assert not any( + "migrated to a config entry and can be safely removed" in record.message + for record in caplog.records + ) + + # Verify a warning was issued about YAML not being imported + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_cannot_connect" + ) diff --git a/tests/components/niko_home_control/conftest.py b/tests/components/niko_home_control/conftest.py index 330488727bb683..b60a597a9ff208 100644 --- a/tests/components/niko_home_control/conftest.py +++ b/tests/components/niko_home_control/conftest.py @@ -45,7 +45,7 @@ def dimmable_light() -> NHCLight: mock.is_dimmable = True mock.name = "dimmable light" mock.suggested_area = "room" - mock.state = 255 + mock.state = 100 return mock diff --git a/tests/components/niko_home_control/snapshots/test_light.ambr b/tests/components/niko_home_control/snapshots/test_light.ambr index 8cf1c0e97d713d..85f07dfed37b35 100644 --- a/tests/components/niko_home_control/snapshots/test_light.ambr +++ b/tests/components/niko_home_control/snapshots/test_light.ambr @@ -41,7 +41,7 @@ # name: test_entities[light.dimmable_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': 255, + 'brightness': 100, 'color_mode': , 'friendly_name': 'dimmable light', 'supported_color_modes': list([ diff --git a/tests/components/niko_home_control/test_light.py b/tests/components/niko_home_control/test_light.py index 476ea95cda8203..7736cbcd40d8fd 100644 --- a/tests/components/niko_home_control/test_light.py +++ b/tests/components/niko_home_control/test_light.py @@ -42,7 +42,7 @@ async def test_entities( @pytest.mark.parametrize( ("light_id", "data", "set_brightness"), [ - (0, {ATTR_ENTITY_ID: "light.light"}, 255), + (0, {ATTR_ENTITY_ID: "light.light"}, None), ( 1, {ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50}, @@ -119,7 +119,7 @@ async def test_updating( assert hass.states.get("light.light").state == STATE_OFF assert hass.states.get("light.dimmable_light").state == STATE_ON - assert hass.states.get("light.dimmable_light").attributes[ATTR_BRIGHTNESS] == 255 + assert hass.states.get("light.dimmable_light").attributes[ATTR_BRIGHTNESS] == 100 dimmable_light.state = 204 await find_update_callback(mock_niko_home_control_connection, 2)(204) diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 3f3b7801c8f349..4a87365a4a6993 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -569,7 +569,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "Speak like a pirate", CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "o5", + CONF_CHAT_MODEL: "gpt-5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", @@ -607,6 +607,52 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CODE_INTERPRETER: False, }, ), + ( # Case 5: code interpreter supported to not supported model + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, + CONF_VERBOSITY: "medium", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5-pro", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5-pro", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_VERBOSITY: "medium", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), ], ) async def test_subentry_switching( diff --git a/tests/components/plant/test_init.py b/tests/components/plant/test_init.py index 122ac3b75d1f85..c4640dbc3de572 100644 --- a/tests/components/plant/test_init.py +++ b/tests/components/plant/test_init.py @@ -80,7 +80,9 @@ async def test_low_battery(hass: HomeAssistant) -> None: async def test_initial_states(hass: HomeAssistant) -> None: """Test plant initialises attributes if sensor already exists.""" hass.states.async_set( - MOISTURE_ENTITY, 5, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS} + MOISTURE_ENTITY, + 5, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM}, ) plant_name = "some_plant" assert await async_setup_component( @@ -101,7 +103,9 @@ async def test_update_states(hass: HomeAssistant) -> None: hass, plant.DOMAIN, {plant.DOMAIN: {plant_name: GOOD_CONFIG}} ) hass.states.async_set( - MOISTURE_ENTITY, 5, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS} + MOISTURE_ENTITY, + 5, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM}, ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") @@ -121,7 +125,7 @@ async def test_unavailable_state(hass: HomeAssistant) -> None: hass.states.async_set( MOISTURE_ENTITY, STATE_UNAVAILABLE, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM}, ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") @@ -139,7 +143,9 @@ async def test_state_problem_if_unavailable(hass: HomeAssistant) -> None: hass, plant.DOMAIN, {plant.DOMAIN: {plant_name: GOOD_CONFIG}} ) hass.states.async_set( - MOISTURE_ENTITY, 42, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS} + MOISTURE_ENTITY, + 42, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM}, ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") @@ -148,7 +154,7 @@ async def test_state_problem_if_unavailable(hass: HomeAssistant) -> None: hass.states.async_set( MOISTURE_ENTITY, STATE_UNAVAILABLE, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM}, ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") diff --git a/tests/components/portainer/snapshots/test_sensor.ambr b/tests/components/portainer/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..87b4be479a9f26 --- /dev/null +++ b/tests/components/portainer/snapshots/test_sensor.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_all_entities[sensor.focused_einstein_image-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.focused_einstein_image', + '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': 'Image', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image', + 'unique_id': 'portainer_test_entry_123_focused_einstein_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.focused_einstein_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'focused_einstein Image', + }), + 'context': , + 'entity_id': 'sensor.focused_einstein_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docker.io/library/redis:7', + }) +# --- +# name: test_all_entities[sensor.funny_chatelet_image-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.funny_chatelet_image', + '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': 'Image', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.funny_chatelet_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'funny_chatelet Image', + }), + 'context': , + 'entity_id': 'sensor.funny_chatelet_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docker.io/library/ubuntu:latest', + }) +# --- +# name: test_all_entities[sensor.practical_morse_image-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.practical_morse_image', + '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': 'Image', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image', + 'unique_id': 'portainer_test_entry_123_practical_morse_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.practical_morse_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'practical_morse Image', + }), + 'context': , + 'entity_id': 'sensor.practical_morse_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docker.io/library/python:3.13-slim', + }) +# --- +# name: test_all_entities[sensor.serene_banach_image-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.serene_banach_image', + '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': 'Image', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image', + 'unique_id': 'portainer_test_entry_123_serene_banach_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.serene_banach_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'serene_banach Image', + }), + 'context': , + 'entity_id': 'sensor.serene_banach_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docker.io/library/nginx:latest', + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image-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.stoic_turing_image', + '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': 'Image', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image', + 'unique_id': 'portainer_test_entry_123_stoic_turing_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'stoic_turing Image', + }), + 'context': , + 'entity_id': 'sensor.stoic_turing_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docker.io/library/postgres:15', + }) +# --- diff --git a/tests/components/portainer/test_sensor.py b/tests/components/portainer/test_sensor.py new file mode 100644 index 00000000000000..2c597a16983b82 --- /dev/null +++ b/tests/components/portainer/test_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the Portainer sensor platform.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_portainer_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 60eda1b9d6415b..594e01034289ca 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1785,7 +1785,7 @@ async def test_unit_conversion_priority_suggested_unit_change_2( UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, 0, ), - (SensorDeviceClass.CONDUCTIVITY, UnitOfConductivity.MICROSIEMENS, 1), + (SensorDeviceClass.CONDUCTIVITY, UnitOfConductivity.MICROSIEMENS_PER_CM, 1), (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.MILLIAMPERE, 0), (SensorDeviceClass.DATA_RATE, UnitOfDataRate.KILOBITS_PER_SECOND, 0), (SensorDeviceClass.DATA_SIZE, UnitOfInformation.KILOBITS, 0), diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 6091bfc96eda05..b8ccc105aad43c 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -230,7 +230,7 @@ "wg2_tmwhss6ntjfc7prs", # https://github.com/home-assistant/core/issues/150662 "wg2_v7owd9tzcaninc36", # https://github.com/orgs/home-assistant/discussions/539 "wk_6kijc7nd", # https://github.com/home-assistant/core/issues/136513 - "wk_IAYz2WK1th0cMLmL", # https://github.com/orgs/home-assistant/discussions/842 + "wk_IAYz2WK1th0cMLmL", # https://github.com/home-assistant/core/issues/150077 "wk_aqoouq7x", # https://github.com/home-assistant/core/issues/146263 "wk_ccpwojhalfxryigz", # https://github.com/home-assistant/core/issues/145551 "wk_cpmgn2cf", # https://github.com/orgs/home-assistant/discussions/684 diff --git a/tests/components/tuya/fixtures/wk_IAYz2WK1th0cMLmL.json b/tests/components/tuya/fixtures/wk_IAYz2WK1th0cMLmL.json index 0eacf2695ef32e..7971a0e226ad17 100644 --- a/tests/components/tuya/fixtures/wk_IAYz2WK1th0cMLmL.json +++ b/tests/components/tuya/fixtures/wk_IAYz2WK1th0cMLmL.json @@ -10,9 +10,9 @@ "online": true, "sub": false, "time_zone": "+01:00", - "active_time": "2018-12-04T17:50:07+00:00", - "create_time": "2018-12-04T17:50:07+00:00", - "update_time": "2025-09-03T07:44:16+00:00", + "active_time": "2022-11-15T08:35:43+00:00", + "create_time": "2022-11-15T08:35:43+00:00", + "update_time": "2022-11-15T08:35:43+00:00", "function": { "switch": { "type": "Boolean", @@ -22,6 +22,16 @@ "type": "Boolean", "value": {} }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 10, + "max": 70, + "scale": 1, + "step": 5 + } + }, "eco": { "type": "Boolean", "value": {} @@ -35,26 +45,14 @@ "scale": 0, "step": 5 } + } + }, + "status_range": { + "eco": { + "type": "Boolean", + "value": {} }, - "Mode": { - "type": "Enum", - "value": { - "range": ["0", "1"] - } - }, - "program": { - "type": "Raw", - "value": { - "maxlen": 128 - } - }, - "tempSwitch": { - "type": "Enum", - "value": { - "range": ["0", "1"] - } - }, - "TempSet": { + "temp_set": { "type": "Integer", "value": { "unit": "\u2103", @@ -63,12 +61,6 @@ "scale": 1, "step": 5 } - } - }, - "status_range": { - "eco": { - "type": "Boolean", - "value": {} }, "switch": { "type": "Boolean", @@ -87,43 +79,14 @@ "scale": 0, "step": 5 } - }, - "floorTemp": { - "type": "Integer", - "value": { - "max": 198, - "min": 0, - "scale": 0, - "step": 5, - "unit": "\u2103" - } - }, - "floortempFunction": { - "type": "Boolean", - "value": {} - }, - "TempSet": { - "type": "Integer", - "value": { - "unit": "\u2103", - "min": 10, - "max": 70, - "scale": 1, - "step": 5 - } } }, "status": { - "switch": false, - "upper_temp": 55, - "eco": true, - "child_lock": false, - "Mode": 1, - "program": "DwYoDwceHhQoORceOhceOxceAAkoAAoeHhQoORceOhceOxceAAkoAAoeHhQoORceOhceOxce", - "floorTemp": 0, - "tempSwitch": 0, - "floortempFunction": true, - "TempSet": 41 + "switch": true, + "temp_set": 46, + "upper_temp": 45, + "eco": false, + "child_lock": true }, "set_up": true, "support_local": true diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 87304e5e9adbd6..0e097db78a8645 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -383,9 +383,9 @@ , , ]), - 'max_temp': 35, - 'min_temp': 7, - 'target_temp_step': 1.0, + 'max_temp': 7.0, + 'min_temp': 1.0, + 'target_temp_step': 0.5, }), 'config_entry_id': , 'config_subentry_id': , @@ -410,7 +410,7 @@ 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'tuya.LmLMc0ht1KW2zYAIkw', 'unit_of_measurement': None, @@ -419,23 +419,24 @@ # name: test_platform_setup_and_discovery[climate.el_termostato_de_la_cocina-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 5.5, + 'current_temperature': 4.5, 'friendly_name': 'El termostato de la cocina', 'hvac_modes': list([ , , ]), - 'max_temp': 35, - 'min_temp': 7, - 'supported_features': , - 'target_temp_step': 1.0, + 'max_temp': 7.0, + 'min_temp': 1.0, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 4.6, }), 'context': , 'entity_id': 'climate.el_termostato_de_la_cocina', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'heat_cool', }) # --- # name: test_platform_setup_and_discovery[climate.empore-entry] diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 041f0eda4f566f..85cf48b60a5af7 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -3238,7 +3238,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_child_lock-entry] diff --git a/tests/components/volvo/snapshots/test_diagnostics.ambr b/tests/components/volvo/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..67f59c44a1912b --- /dev/null +++ b/tests/components/volvo/snapshots/test_diagnostics.ambr @@ -0,0 +1,535 @@ +# serializer version: 1 +# name: test_entry_diagnostics[xc40_electric_2024] + dict({ + 'Volvo fast interval coordinator': dict({ + 'centralLock': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:20:20.570000+00:00', + 'unit': None, + 'value': 'LOCKED', + }), + 'frontLeftDoor': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:20:20.570000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + 'frontLeftWindow': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:28:12.202000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + 'frontRightDoor': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:20:20.570000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + 'frontRightWindow': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:28:12.202000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + 'hood': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:20:20.570000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + 'rearLeftDoor': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:20:20.570000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + 'rearLeftWindow': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:28:12.202000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + 'rearRightDoor': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:20:20.570000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + 'rearRightWindow': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:28:12.202000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + 'sunroof': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:28:12.202000+00:00', + 'unit': None, + 'value': 'UNSPECIFIED', + }), + 'tailgate': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:20:20.570000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + 'tankLid': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:20:20.570000+00:00', + 'unit': None, + 'value': 'CLOSED', + }), + }), + 'Volvo medium interval coordinator': dict({ + 'batteryChargeLevel': dict({ + 'extra_data': dict({ + 'updated_at': '2025-07-02T08:51:23Z', + }), + 'status': 'OK', + 'timestamp': None, + 'unit': 'percentage', + 'value': 53, + }), + 'chargerConnectionStatus': dict({ + 'extra_data': dict({ + 'updated_at': '2025-07-02T08:51:23Z', + }), + 'status': 'OK', + 'timestamp': None, + 'unit': None, + 'value': 'CONNECTED', + }), + 'chargerPowerStatus': dict({ + 'extra_data': dict({ + 'updated_at': '2025-07-02T08:51:23Z', + }), + 'status': 'OK', + 'timestamp': None, + 'unit': None, + 'value': 'PROVIDING_POWER', + }), + 'chargingCurrentLimit': dict({ + 'extra_data': dict({ + 'updated_at': '2024-03-05T08:38:44Z', + }), + 'status': 'OK', + 'timestamp': None, + 'unit': 'ampere', + 'value': 32, + }), + 'chargingPower': dict({ + 'extra_data': dict({ + 'updated_at': '2025-07-02T08:51:23Z', + }), + 'status': 'OK', + 'timestamp': None, + 'unit': 'watts', + 'value': 1386, + }), + 'chargingStatus': dict({ + 'extra_data': dict({ + 'updated_at': '2025-07-02T08:51:23Z', + }), + 'status': 'OK', + 'timestamp': None, + 'unit': None, + 'value': 'CHARGING', + }), + 'chargingType': dict({ + 'extra_data': dict({ + 'updated_at': '2025-07-02T08:51:23Z', + }), + 'status': 'OK', + 'timestamp': None, + 'unit': None, + 'value': 'AC', + }), + 'electricRange': dict({ + 'extra_data': dict({ + 'updated_at': '2025-07-02T08:51:23Z', + }), + 'status': 'OK', + 'timestamp': None, + 'unit': 'mi', + 'value': 150, + }), + 'estimatedChargingTimeToTargetBatteryChargeLevel': dict({ + 'extra_data': dict({ + 'updated_at': '2025-07-02T08:51:23Z', + }), + 'status': 'OK', + 'timestamp': None, + 'unit': 'minutes', + 'value': 1440, + }), + 'targetBatteryChargeLevel': dict({ + 'extra_data': dict({ + 'updated_at': '2024-09-22T09:40:12Z', + }), + 'status': 'OK', + 'timestamp': None, + 'unit': 'percentage', + 'value': 90, + }), + }), + 'Volvo slow interval coordinator': dict({ + 'availabilityStatus': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:32:26.169000+00:00', + 'unit': None, + 'value': 'AVAILABLE', + }), + }), + 'Volvo very slow interval coordinator': dict({ + 'averageEnergyConsumption': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:53:44.785000+00:00', + 'unit': 'kWh/100km', + 'value': 22.6, + }), + 'averageSpeed': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': 'km/h', + 'value': 53, + }), + 'averageSpeedAutomatic': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': 'km/h', + 'value': 26, + }), + 'battery_capacity_kwh': dict({ + 'extra_data': dict({ + }), + 'value': 81.608, + }), + 'brakeFluidLevelWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'brakeLightCenterWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'brakeLightLeftWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'brakeLightRightWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'daytimeRunningLightLeftWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'daytimeRunningLightRightWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'distanceToEmptyBattery': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:30:08.338000+00:00', + 'unit': 'km', + 'value': 250, + }), + 'distanceToService': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': 'km', + 'value': 29000, + }), + 'engineCoolantLevelWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'engineHoursToService': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': 'h', + 'value': 1266, + }), + 'fogLightFrontWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'fogLightRearWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'frontLeft': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'UNSPECIFIED', + }), + 'frontRight': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'UNSPECIFIED', + }), + 'hazardLightsWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'UNSPECIFIED', + }), + 'highBeamLeftWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'highBeamRightWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'lowBeamLeftWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'lowBeamRightWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'odometer': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': 'km', + 'value': 30000, + }), + 'oilLevelWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'positionLightFrontLeftWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'positionLightFrontRightWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'positionLightRearLeftWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'positionLightRearRightWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'rearLeft': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'UNSPECIFIED', + }), + 'rearRight': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'UNSPECIFIED', + }), + 'registrationPlateLightWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'reverseLightsWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'UNSPECIFIED', + }), + 'serviceWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'sideMarkLightsWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'timeToService': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': 'months', + 'value': 23, + }), + 'tripMeterAutomatic': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': 'km', + 'value': 18.2, + }), + 'tripMeterManual': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': 'km', + 'value': 3822.9, + }), + 'turnIndicationFrontLeftWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'turnIndicationFrontRightWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'turnIndicationRearLeftWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'turnIndicationRearRightWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + 'washerFluidLevelWarning': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T14:18:56.849000+00:00', + 'unit': None, + 'value': 'NO_WARNING', + }), + }), + 'entry_data': dict({ + 'api_key': '**REDACTED**', + 'auth_implementation': 'volvo', + 'token': dict({ + 'access_token': '**REDACTED**', + 'expires_at': 1759919745.7328658, + 'expires_in': 60, + 'refresh_token': '**REDACTED**', + 'token_type': 'Bearer', + }), + 'vin': '**REDACTED**', + }), + 'vehicle': dict({ + 'battery_capacity_kwh': 81.608, + 'description': dict({ + 'extra_data': dict({ + }), + 'model': 'XC40', + 'steering': 'LEFT', + 'upholstery': 'null', + }), + 'external_colour': 'Silver Dawn', + 'extra_data': dict({ + }), + 'fuel_type': 'ELECTRIC', + 'gearbox': 'AUTOMATIC', + 'images': dict({ + 'exterior_image_url': 'https://cas.volvocars.com/image/dynamic/MY24_0000/123/exterior-v4/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920', + 'extra_data': dict({ + }), + 'internal_image_url': 'https://cas.volvocars.com/image/dynamic/MY24_0000/123/interior-v4/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920', + }), + 'model_year': 2024, + 'vin': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/volvo/test_diagnostics.py b/tests/components/volvo/test_diagnostics.py new file mode 100644 index 00000000000000..1ca0c99eef7528 --- /dev/null +++ b/tests/components/volvo/test_diagnostics.py @@ -0,0 +1,35 @@ +"""Test Volvo diagnostics.""" + +from collections.abc import Awaitable, Callable + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import CONF_TOKEN +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_api") +async def test_entry_diagnostics( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry diagnostics.""" + + assert await setup_integration() + await hass.async_block_till_done() + + # Give it a fixed timestamp so it won't change with every test run + mock_config_entry.data[CONF_TOKEN]["expires_at"] = 1759919745.7328658 + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 32cf3edf010709..a0acbd305e9b02 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -22,7 +22,7 @@ HomeAssistant, callback, ) -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.event import ( @@ -4975,43 +4975,25 @@ def on_state_report(event: Event[EventStateReportedData]) -> None: } -async def test_async_track_template_no_hass_deprecated( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test async_track_template with a template without hass is deprecated.""" - message = ( - "Detected code that calls async_track_template_result with template without " - "hass. This will stop working in Home Assistant 2025.10, please " - "report this issue" - ) +async def test_async_track_template_no_hass_fails(hass: HomeAssistant) -> None: + """Test async_track_template with a template without hass now fails.""" + message = "Calls async_track_template_result with template without hass" - async_track_template(hass, Template("blah"), lambda x, y, z: None) - assert message in caplog.text - caplog.clear() + with pytest.raises(HomeAssistantError, match=message): + async_track_template(hass, Template("blah"), lambda x, y, z: None) async_track_template(hass, Template("blah", hass), lambda x, y, z: None) - assert message not in caplog.text - caplog.clear() -async def test_async_track_template_result_no_hass_deprecated( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test async_track_template_result with a template without hass is deprecated.""" - message = ( - "Detected code that calls async_track_template_result with template without " - "hass. This will stop working in Home Assistant 2025.10, please " - "report this issue" - ) +async def test_async_track_template_result_no_hass_fails(hass: HomeAssistant) -> None: + """Test async_track_template_result with a template without hass now fails.""" + message = "Calls async_track_template_result with template without hass" - async_track_template_result( - hass, [TrackTemplate(Template("blah"), None)], lambda x, y, z: None - ) - assert message in caplog.text - caplog.clear() + with pytest.raises(HomeAssistantError, match=message): + async_track_template_result( + hass, [TrackTemplate(Template("blah"), None)], lambda x, y, z: None + ) async_track_template_result( hass, [TrackTemplate(Template("blah", hass), None)], lambda x, y, z: None ) - assert message not in caplog.text - caplog.clear() diff --git a/tests/test_const.py b/tests/test_const.py index 426e3c598a6c1c..10fc5241b9eea4 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -1,19 +1,12 @@ """Test const module.""" from enum import Enum -import logging -import sys -from unittest.mock import Mock, patch import pytest from homeassistant import const -from .common import ( - extract_stack_to_frame, - help_test_all, - import_and_test_deprecated_constant, -) +from .common import help_test_all, import_and_test_deprecated_constant def _create_tuples( @@ -48,78 +41,3 @@ def test_deprecated_constant_name_changes( replacement, breaks_in_version, ) - - -def test_deprecated_unit_of_conductivity_alias() -> None: - """Test UnitOfConductivity deprecation.""" - - # Test the deprecated members are aliases - assert set(const.UnitOfConductivity) == {"S/cm", "μS/cm", "mS/cm"} - - -def test_deprecated_unit_of_conductivity_members( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test UnitOfConductivity deprecation.""" - - module_name = "config.custom_components.hue.light" - filename = f"/home/paulus/{module_name.replace('.', '/')}.py" - - with ( - patch.dict(sys.modules, {module_name: Mock(__file__=filename)}), - patch( - "homeassistant.helpers.frame.linecache.getline", - return_value="await session.close()", - ), - patch( - "homeassistant.helpers.frame.get_current_frame", - return_value=extract_stack_to_frame( - [ - Mock( - filename="/home/paulus/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - Mock( - filename=filename, - lineno="23", - line="await session.close()", - ), - Mock( - filename="/home/paulus/aiohue/lights.py", - lineno="2", - line="something()", - ), - ] - ), - ), - ): - const.UnitOfConductivity.SIEMENS # noqa: B018 - const.UnitOfConductivity.MICROSIEMENS # noqa: B018 - const.UnitOfConductivity.MILLISIEMENS # noqa: B018 - - assert len(caplog.record_tuples) == 3 - - def deprecation_message(member: str, replacement: str) -> str: - return ( - f"The deprecated enum member UnitOfConductivity.{member} was used from hue. " - "It will be removed in HA Core 2025.11.0. Use UnitOfConductivity." - f"{replacement} instead, please report it to the author of the 'hue' custom" - " integration" - ) - - assert ( - const.__name__, - logging.WARNING, - deprecation_message("SIEMENS", "SIEMENS_PER_CM"), - ) in caplog.record_tuples - assert ( - const.__name__, - logging.WARNING, - deprecation_message("MICROSIEMENS", "MICROSIEMENS_PER_CM"), - ) in caplog.record_tuples - assert ( - const.__name__, - logging.WARNING, - deprecation_message("MILLISIEMENS", "MILLISIEMENS_PER_CM"), - ) in caplog.record_tuples diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index d9377779b68e83..7b7c37527294c0 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -281,48 +281,6 @@ ), ], ConductivityConverter: [ - # Deprecated to deprecated - (5, UnitOfConductivity.SIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS), - (5, UnitOfConductivity.SIEMENS, 5e6, UnitOfConductivity.MICROSIEMENS), - (5, UnitOfConductivity.MILLISIEMENS, 5e3, UnitOfConductivity.MICROSIEMENS), - (5, UnitOfConductivity.MILLISIEMENS, 5e-3, UnitOfConductivity.SIEMENS), - (5e6, UnitOfConductivity.MICROSIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS), - (5e6, UnitOfConductivity.MICROSIEMENS, 5, UnitOfConductivity.SIEMENS), - # Deprecated to new - (5, UnitOfConductivity.SIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS_PER_CM), - (5, UnitOfConductivity.SIEMENS, 5e6, UnitOfConductivity.MICROSIEMENS_PER_CM), - ( - 5, - UnitOfConductivity.MILLISIEMENS, - 5e3, - UnitOfConductivity.MICROSIEMENS_PER_CM, - ), - (5, UnitOfConductivity.MILLISIEMENS, 5e-3, UnitOfConductivity.SIEMENS_PER_CM), - ( - 5e6, - UnitOfConductivity.MICROSIEMENS, - 5e3, - UnitOfConductivity.MILLISIEMENS_PER_CM, - ), - (5e6, UnitOfConductivity.MICROSIEMENS, 5, UnitOfConductivity.SIEMENS_PER_CM), - # New to deprecated - (5, UnitOfConductivity.SIEMENS_PER_CM, 5e3, UnitOfConductivity.MILLISIEMENS), - (5, UnitOfConductivity.SIEMENS_PER_CM, 5e6, UnitOfConductivity.MICROSIEMENS), - ( - 5, - UnitOfConductivity.MILLISIEMENS_PER_CM, - 5e3, - UnitOfConductivity.MICROSIEMENS, - ), - (5, UnitOfConductivity.MILLISIEMENS_PER_CM, 5e-3, UnitOfConductivity.SIEMENS), - ( - 5e6, - UnitOfConductivity.MICROSIEMENS_PER_CM, - 5e3, - UnitOfConductivity.MILLISIEMENS, - ), - (5e6, UnitOfConductivity.MICROSIEMENS_PER_CM, 5, UnitOfConductivity.SIEMENS), - # New to new ( 5, UnitOfConductivity.SIEMENS_PER_CM,