diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 37b6f0aeabd5dc..d0f76b01c7163a 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -554,7 +554,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: @property @override - def fan_mode(self) -> str: + def fan_mode(self) -> str | None: """Return the fan setting.""" fan_speed = self._device.current_fan_speed @@ -563,6 +563,10 @@ def fan_mode(self) -> str: return self.fan_zero_mode if self._device.fan_speed_mode is FanSpeedMode.STEP: + # DPT 5.010 fits any 1-byte value (0-255), so a gateway may report + # a step beyond the configured fan_max_step + if fan_speed >= len(self._attr_fan_modes): + return None return self._attr_fan_modes[fan_speed] # Find the closest fan mode percentage diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index 0b34a86cb324f3..38b77f89c72a5a 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -392,6 +392,10 @@ async def async_step_authorize( LOGGER.error("Bluetooth pairing was rejected: %s", err) self._pair_error = {"base": "pair_failed"} return self.async_show_progress_done(next_step_id="instructions") + except Exception: + # async_remove() only runs if the flow is still tracked when this step raises. + await self._async_disconnect() + raise return self.async_show_progress_done(next_step_id="pair") async def _async_disconnect(self) -> None: diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json index 3685bcf9110166..7125aac33dfd11 100644 --- a/homeassistant/components/waterfurnace/manifest.json +++ b/homeassistant/components/waterfurnace/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["waterfurnace"], "quality_scale": "bronze", - "requirements": ["waterfurnace==1.9.0"] + "requirements": ["waterfurnace==1.9.2"] } diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index f4be335a45f719..4b4d35d9dc2f5e 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -40,7 +40,7 @@ EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -48,6 +48,7 @@ async_get_config_entry_implementation, ) from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.start import async_at_started from .const import DEFAULT_TITLE, DOMAIN, LOGGER from .coordinator import ( @@ -164,22 +165,23 @@ async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: async_call_later(hass, 30, webhook_manager.register_webhook) ) + @callback + def _async_register_webhook_later(_: HomeAssistant) -> None: + """Register the webhook once Home Assistant answers for it.""" + entry.async_on_unload( + async_call_later( + hass, WEBHOOK_REGISTER_DELAY, webhook_manager.register_webhook + ) + ) + if cloud.async_active_subscription(hass): if cloud.async_is_connected(hass): - entry.async_on_unload( - async_call_later( - hass, WEBHOOK_REGISTER_DELAY, webhook_manager.register_webhook - ) - ) + entry.async_on_unload(async_at_started(hass, _async_register_webhook_later)) entry.async_on_unload( cloud.async_listen_connection_change(hass, manage_cloudhook) ) else: - entry.async_on_unload( - async_call_later( - hass, WEBHOOK_REGISTER_DELAY, webhook_manager.register_webhook - ) - ) + entry.async_on_unload(async_at_started(hass, _async_register_webhook_later)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/requirements_all.txt b/requirements_all.txt index a885b555f30b5a..1b4bc4d6022e8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3434,7 +3434,7 @@ wallbox==0.9.0 watchdog==6.0.0 # homeassistant.components.waterfurnace -waterfurnace==1.9.0 +waterfurnace==1.9.2 # homeassistant.components.watergate watergate-local-api==2026.2.2 diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 154681fc33ccd2..2d01aae29b2f7f 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -502,6 +502,45 @@ async def test_fan_speed_3_steps(hass: HomeAssistant, knx: KNXTestKit) -> None: knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off") +@pytest.mark.parametrize("raw_value", [0x04, 0xFF]) +async def test_fan_speed_step_out_of_range( + hass: HomeAssistant, knx: KNXTestKit, raw_value: int +) -> None: + """Test that fan step values beyond fan_max_step don't crash the entity.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", + ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", + ClimateConf.FAN_SPEED_MODE: "step", + ClimateConf.FAN_MAX_STEP: 3, + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + + # a gateway may report a step value beyond the configured fan_max_step + await knx.receive_write("1/2/7", (raw_value,)) + await hass.async_block_till_done() + + # the invalid telegram is published without breaking the state write + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode=None) + + async def test_fan_speed_2_steps(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate fan speed 2 steps.""" await knx.setup_integration( diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index 8fb91324687970..588e79aa7e7a34 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -998,6 +998,37 @@ async def _pair() -> None: vehicle.pair.assert_awaited_once() +async def test_subentry_authorize_unexpected_error_disconnects( + hass: HomeAssistant, +) -> None: + """An exception outside the handled set still disconnects before propagating.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle(on_whitelist=False) + vehicle.pair = AsyncMock(side_effect=ValueError("boom")) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "instructions" + + with pytest.raises(ValueError, match="boom"): + await hass.config_entries.subentries.async_configure(result["flow_id"], {}) + + vehicle.disconnect.assert_awaited_once() + assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + + async def test_subentry_authorize_existing_key_finishes(hass: HomeAssistant) -> None: """Approving the key after a timeout, then retrying, completes the pairing.""" entry = await _setup_account_entry(hass) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 1a69cc74786d3b..3c5d231857fdfb 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -26,8 +26,8 @@ from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -857,6 +857,30 @@ async def test_webhook_subscription_auth_failure( ) +async def test_webhook_subscription_waits_for_start( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the webhook is only subscribed once Home Assistant has started.""" + hass.set_state(CoreState.not_running) + + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + # Withings validates the callback URL by calling it, which cannot work + # while our own HTTP server is still starting up + assert withings.subscribe_notification.call_count == 0 + + hass.set_state(CoreState.running) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + await prepare_webhook_setup(hass, freezer) + + assert withings.subscribe_notification.call_count == 6 + + async def test_webhook_subscription_invalid_params( hass: HomeAssistant, withings: AsyncMock,