diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index 7809ba54c53f97..bd0d876c710cd9 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -7,11 +7,12 @@ from typing import TYPE_CHECKING from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot +import voluptuous as vol from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import DOMAIN @@ -33,28 +34,27 @@ SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule" -def _parse_time_value(value: time | str) -> time: - """Parse a time value from either a time object or string. +# Schema for a single time slot +_SLOT_SCHEMA = vol.Schema( + { + vol.Required("start_time"): cv.time, + vol.Required("end_time"): cv.time, + } +) - Raises ServiceValidationError if the format is invalid. - """ - if isinstance(value, time): - return value - - if isinstance(value, str): - try: - parts = value.split(":") - return time(int(parts[0]), int(parts[1])) - except (ValueError, IndexError): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_time_format", - ) from None - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_time_format", - ) +SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + } +) def _convert_time_slots_to_day_schedule( @@ -62,8 +62,8 @@ def _convert_time_slots_to_day_schedule( ) -> DaySchedule | None: """Convert list of time slot dicts to a DaySchedule object. - Example: [{"start_time": "06:00", "end_time": "08:00"}, - {"start_time": "17:00", "end_time": "21:00"}] + Example: [{"start_time": time(6, 0), "end_time": time(8, 0)}, + {"start_time": time(17, 0), "end_time": time(21, 0)}] becomes: DaySchedule with two TimeSlot objects None returns None (don't modify this day). @@ -77,31 +77,27 @@ def _convert_time_slots_to_day_schedule( time_slots = [] for slot in slots: - start = slot.get("start_time") - end = slot.get("end_time") - - if start and end: - start_time = _parse_time_value(start) - end_time = _parse_time_value(end) - - # Validate that end time is after start time - if end_time <= start_time: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="end_time_before_start_time", - translation_placeholders={ - "start_time": start_time.strftime("%H:%M"), - "end_time": end_time.strftime("%H:%M"), - }, - ) - - time_slots.append(TimeSlot(start=start_time, end=end_time)) - LOGGER.debug( - "Created time slot: %s-%s", - start_time.strftime("%H:%M"), - end_time.strftime("%H:%M"), + start_time = slot["start_time"] + end_time = slot["end_time"] + + # Validate that end time is after start time + if end_time <= start_time: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="end_time_before_start_time", + translation_placeholders={ + "start_time": start_time.strftime("%H:%M"), + "end_time": end_time.strftime("%H:%M"), + }, ) + time_slots.append(TimeSlot(start=start_time, end=end_time)) + LOGGER.debug( + "Created time slot: %s-%s", + start_time.strftime("%H:%M"), + end_time.strftime("%H:%M"), + ) + LOGGER.debug("Created DaySchedule with %d slots", len(time_slots)) return DaySchedule(slots=time_slots) @@ -214,4 +210,5 @@ def async_setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SET_HOT_WATER_SCHEDULE, set_hot_water_schedule, + schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA, ) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index c6f4e4e3eb153f..2053bf9025507a 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.5.0", "home-assistant-intents==2025.12.2"] + "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"] } diff --git a/homeassistant/components/pooldose/config_flow.py b/homeassistant/components/pooldose/config_flow.py index 73eb12d7562864..d15de677960566 100644 --- a/homeassistant/components/pooldose/config_flow.py +++ b/homeassistant/components/pooldose/config_flow.py @@ -114,32 +114,72 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - if not user_input: - return self.async_show_form( - step_id="user", - data_schema=SCHEMA_DEVICE, + if user_input is not None: + host = user_input[CONF_HOST] + serial_number, api_versions, errors = await self._validate_host(host) + if errors: + return self.async_show_form( + step_id="user", + data_schema=SCHEMA_DEVICE, + errors=errors, + # Handle API version info for error display; pass version info when available + # or None when api_versions is None to avoid displaying version details + description_placeholders={ + "api_version_is": api_versions.get("api_version_is") or "", + "api_version_should": api_versions.get("api_version_should") + or "", + } + if api_versions + else None, + ) + + await self.async_set_unique_id(serial_number, raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"PoolDose {serial_number}", + data={CONF_HOST: host}, ) - host = user_input[CONF_HOST] - serial_number, api_versions, errors = await self._validate_host(host) - if errors: - return self.async_show_form( - step_id="user", - data_schema=SCHEMA_DEVICE, - errors=errors, - # Handle API version info for error display; pass version info when available - # or None when api_versions is None to avoid displaying version details - description_placeholders={ - "api_version_is": api_versions.get("api_version_is") or "", - "api_version_should": api_versions.get("api_version_should") or "", - } - if api_versions - else None, + return self.async_show_form( + step_id="user", + data_schema=SCHEMA_DEVICE, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure to change the device host/IP for an existing entry.""" + if user_input is not None: + host = user_input[CONF_HOST] + serial_number, api_versions, errors = await self._validate_host(host) + if errors: + return self.async_show_form( + step_id="reconfigure", + data_schema=SCHEMA_DEVICE, + errors=errors, + # Handle API version info for error display identical to other steps + description_placeholders={ + "api_version_is": api_versions.get("api_version_is") or "", + "api_version_should": api_versions.get("api_version_should") + or "", + } + if api_versions + else None, + ) + + # Ensure new serial number matches the existing entry unique_id (serial number) + if serial_number != self._get_reconfigure_entry().unique_id: + return self.async_abort(reason="wrong_device") + + # Update the existing config entry with the new host and schedule reload + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data_updates={CONF_HOST: host} ) - await self.async_set_unique_id(serial_number, raise_on_progress=False) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=f"PoolDose {serial_number}", - data={CONF_HOST: host}, + return self.async_show_form( + step_id="reconfigure", + # Pre-fill with current host from the entry being reconfigured + data_schema=self.add_suggested_values_to_schema( + SCHEMA_DEVICE, self._get_reconfigure_entry().data + ), ) diff --git a/homeassistant/components/pooldose/quality_scale.yaml b/homeassistant/components/pooldose/quality_scale.yaml index 07514d681afbe6..7c8ce99715fb02 100644 --- a/homeassistant/components/pooldose/quality_scale.yaml +++ b/homeassistant/components/pooldose/quality_scale.yaml @@ -60,7 +60,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: This integration does not provide repair issues, as it is designed for a single PoolDose device with a fixed configuration. diff --git a/homeassistant/components/pooldose/strings.json b/homeassistant/components/pooldose/strings.json index c80353c5004c8b..3d50d28faac190 100644 --- a/homeassistant/components/pooldose/strings.json +++ b/homeassistant/components/pooldose/strings.json @@ -4,7 +4,9 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_device_info": "Unable to retrieve device information", - "no_serial_number": "No serial number found on the device" + "no_serial_number": "No serial number found on the device", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The provided device does not match the configured device" }, "error": { "api_not_set": "API version not found in device response. Device firmware may not be compatible with this integration.", @@ -20,6 +22,14 @@ "description": "A PoolDose device was found on your network at {ip} with MAC address {mac}.\n\nDo you want to add {name} to Home Assistant?", "title": "Confirm DHCP discovered PoolDose device" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::pooldose::config::step::user::data_description::host%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index a5826e9bb8c086..56e31c771e426e 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -19,6 +19,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import color as color_util from .entity import ( ReolinkChannelCoordinatorEntity, @@ -157,16 +158,16 @@ def is_on(self) -> bool: @property def brightness(self) -> int | None: - """Return the brightness of this light between 0.255.""" + """Return the brightness of this light between 1.255.""" assert self.entity_description.get_brightness_fn is not None bright_pct = self.entity_description.get_brightness_fn( self._host.api, self._channel ) - if bright_pct is None: + if not bright_pct: return None - return round(255 * bright_pct / 100.0) + return color_util.value_to_brightness((1, 100), bright_pct) @property def color_temp_kelvin(self) -> int | None: @@ -189,7 +190,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: if ( brightness := kwargs.get(ATTR_BRIGHTNESS) ) is not None and self.entity_description.set_brightness_fn is not None: - brightness_pct = int(brightness / 255.0 * 100) + brightness_pct = round(color_util.brightness_to_value((1, 100), brightness)) await self.entity_description.set_brightness_fn( self._host.api, self._channel, brightness_pct ) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 5188e7fd41429b..dd1ecca42279c5 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -958,6 +958,23 @@ def _async_regroup(group: list[str]) -> None: # as those "invisible" speakers will bypass the single speaker check return + # Clear coordinator on speakers that are no longer in this group + old_members = set(self.sonos_group[1:]) + new_members = set(sonos_group[1:]) + removed_members = old_members - new_members + for removed_speaker in removed_members: + # Only clear if this speaker was coordinated by self and in the same group + if ( + removed_speaker.coordinator == self + and removed_speaker.sonos_group is self.sonos_group + ): + _LOGGER.debug( + "Zone %s Cleared coordinator [%s] (removed from group)", + removed_speaker.zone_name, + self.zone_name, + ) + removed_speaker.clear_coordinator() + self.coordinator = None self.sonos_group = sonos_group self.sonos_group_entities = sonos_group_entities @@ -990,6 +1007,19 @@ async def _async_handle_group_event(event: SonosEvent | None) -> None: return _async_handle_group_event(event) + @callback + def clear_coordinator(self) -> None: + """Clear coordinator from speaker.""" + self.coordinator = None + self.sonos_group = [self] + entity_registry = er.async_get(self.hass) + speaker_entity_id = cast( + str, + entity_registry.async_get_entity_id(MP_DOMAIN, DOMAIN, self.uid), + ) + self.sonos_group_entities = [speaker_entity_id] + self.async_write_entity_states() + @soco_error() def join(self, speakers: list[SonosSpeaker]) -> list[SonosSpeaker]: """Form a group with other players.""" @@ -1038,7 +1068,6 @@ def unjoin(self) -> None: if self.sonos_group == [self]: return self.soco.unjoin() - self.coordinator = None @staticmethod async def unjoin_multi( diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index cd349cd34149b1..db9a53ac154909 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2025.5"] + "requirements": ["total-connect-client==2025.12.2"] } diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index f78d3655d540e6..ca6bad06224655 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -105,7 +105,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bo try: await controller.connect() except VelbusConnectionFailed as error: - raise ConfigEntryNotReady("Cannot connect to Velbus") from error + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_failed", + ) from error task = hass.async_create_task(velbus_scan_task(controller, hass, entry.entry_id)) entry.runtime_data = VelbusData(controller=controller, scan_task=task) diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index e31d9a974161e2..4eb9db94ec76c1 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -65,7 +65,7 @@ def preset_mode(self) -> str | None: ) @property - def current_temperature(self) -> int | None: + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._channel.get_state() diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index af7ea8bbcccf22..aa221b3ca1078c 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -66,6 +66,7 @@ async def async_will_remove_from_hass(self) -> None: self._channel.remove_on_status_update(self._on_update) async def _on_update(self) -> None: + """Handle status updates from the channel.""" self.async_write_ha_state() @@ -80,8 +81,13 @@ async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: try: await func(self, *args, **kwargs) except OSError as exc: + entity_name = self.name if isinstance(self.name, str) else "Unknown" raise HomeAssistantError( - f"Could not execute {func.__name__} service for {self.name}" + translation_domain=DOMAIN, + translation_key="api_call_failed", + translation_placeholders={ + "entity": entity_name, + }, ) from exc return cmd_wrapper diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index ee5a89160a5899..0b1141a7da0e93 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.12.0"], + "requirements": ["velbus-aio==2026.1.0"], "usb": [ { "pid": "0B1B", diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 83b8bce6c0c2b6..81907f50c35850 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -25,8 +25,8 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: todo integration-owner: done log-when-unavailable: done @@ -56,7 +56,7 @@ rules: entity-device-class: todo entity-disabled-by-default: done entity-translations: todo - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index ff51ad066e5a01..d9432afa08e3e7 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -57,8 +57,14 @@ } }, "exceptions": { + "api_call_failed": { + "message": "Action execute for {entity} failed." + }, "clear_cache_failed": { - "message": "Could not cleat the Velbus cache: {error}" + "message": "Could not clear the Velbus cache: {error}" + }, + "connection_failed": { + "message": "Could not connect to Velbus." }, "integration_not_found": { "message": "Integration \"{target}\" not found in registry." diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7f2d846a6d3479..742723648323e4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20251229.0 -home-assistant-intents==2025.12.2 +home-assistant-intents==2026.1.1 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 @@ -70,9 +70,9 @@ typing-extensions>=4.15.0,<5.0 ulid-transform==1.5.2 urllib3>=2.0 uv==0.9.17 -voluptuous-openapi==0.2.0 +voluptuous-openapi==0.3.0 voluptuous-serialize==2.7.0 -voluptuous==0.15.2 +voluptuous==0.16.0 webrtc-models==0.3.0 yarl==1.22.0 zeroconf==0.148.0 diff --git a/pyproject.toml b/pyproject.toml index 97930569ceb56b..57652bc36aa4a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,9 +76,9 @@ dependencies = [ "ulid-transform==1.5.2", "urllib3>=2.0", "uv==0.9.17", - "voluptuous==0.15.2", + "voluptuous==0.16.0", "voluptuous-serialize==2.7.0", - "voluptuous-openapi==0.2.0", + "voluptuous-openapi==0.3.0", "yarl==1.22.0", "webrtc-models==0.3.0", "zeroconf==0.148.0", diff --git a/requirements.txt b/requirements.txt index be409ed1b60c86..bc7d83e3f22369 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2 hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-intents==2025.12.2 +home-assistant-intents==2026.1.1 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 @@ -54,9 +54,9 @@ typing-extensions>=4.15.0,<5.0 ulid-transform==1.5.2 urllib3>=2.0 uv==0.9.17 -voluptuous-openapi==0.2.0 +voluptuous-openapi==0.3.0 voluptuous-serialize==2.7.0 -voluptuous==0.15.2 +voluptuous==0.16.0 webrtc-models==0.3.0 yarl==1.22.0 zeroconf==0.148.0 diff --git a/requirements_all.txt b/requirements_all.txt index 835941207b96d6..3106fa17dfeb0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1216,7 +1216,7 @@ holidays==0.84 home-assistant-frontend==20251229.0 # homeassistant.components.conversation -home-assistant-intents==2025.12.2 +home-assistant-intents==2026.1.1 # homeassistant.components.gentex_homelink homelink-integration-api==0.0.1 @@ -3039,7 +3039,7 @@ tololib==1.2.2 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2025.5 +total-connect-client==2025.12.2 # homeassistant.components.tplink_lte tp-connected==0.0.4 @@ -3122,7 +3122,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.12.0 +velbus-aio==2026.1.0 # homeassistant.components.venstar venstarcolortouch==0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 430defc2e3e319..6c1512b1984e6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ holidays==0.84 home-assistant-frontend==20251229.0 # homeassistant.components.conversation -home-assistant-intents==2025.12.2 +home-assistant-intents==2026.1.1 # homeassistant.components.gentex_homelink homelink-integration-api==0.0.1 @@ -2533,7 +2533,7 @@ tololib==1.2.2 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2025.5 +total-connect-client==2025.12.2 # homeassistant.components.tplink_omada tplink-omada-client==1.5.3 @@ -2607,7 +2607,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.12.0 +velbus-aio==2026.1.0 # homeassistant.components.venstar venstarcolortouch==0.21 diff --git a/tests/components/bsblan/test_services.py b/tests/components/bsblan/test_services.py index 303e49ac42f919..f0955a7ac7a8d6 100644 --- a/tests/components/bsblan/test_services.py +++ b/tests/components/bsblan/test_services.py @@ -6,6 +6,7 @@ from bsblan import BSBLANError, DaySchedule, TimeSlot import pytest +import voluptuous as vol from homeassistant.components.bsblan.const import DOMAIN from homeassistant.components.bsblan.services import ( @@ -198,9 +199,7 @@ async def test_no_config_entry_for_device( SERVICE_SET_HOT_WATER_SCHEDULE, { "device_id": device_entry.id, - "monday_slots": [ - {"start_time": time(6, 0), "end_time": time(8, 0)}, - ], + "monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}], }, blocking=True, ) @@ -274,14 +273,10 @@ async def test_api_error( [ (time(13, 0), time(11, 0), "end_time_before_start_time"), ("13:00", "11:00", "end_time_before_start_time"), - ("invalid", "08:00", "invalid_time_format"), - ("06:00", "not-a-time", "invalid_time_format"), ], ids=[ "time_objects_end_before_start", "strings_end_before_start", - "invalid_start_time_format", - "invalid_end_time_format", ], ) async def test_time_validation_errors( @@ -395,22 +390,20 @@ async def test_non_standard_time_types( device_entry: dr.DeviceEntry, ) -> None: """Test service with non-standard time types raises error.""" - # Test with integer time values (shouldn't happen but need coverage) - with pytest.raises(ServiceValidationError) as exc_info: + # Test with integer time values - schema validation will reject these + with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( DOMAIN, SERVICE_SET_HOT_WATER_SCHEDULE, { "device_id": device_entry.id, "monday_slots": [ - {"start_time": 600, "end_time": 800}, # Non-standard types + {"start_time": 600, "end_time": 800}, ], }, blocking=True, ) - assert exc_info.value.translation_key == "invalid_time_format" - async def test_async_setup_services( hass: HomeAssistant, diff --git a/tests/components/pooldose/test_config_flow.py b/tests/components/pooldose/test_config_flow.py index 354808c51d3c93..930d5cbd7745cf 100644 --- a/tests/components/pooldose/test_config_flow.py +++ b/tests/components/pooldose/test_config_flow.py @@ -1,8 +1,10 @@ """Test the PoolDose config flow.""" +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.pooldose.const import DOMAIN @@ -14,7 +16,7 @@ from .conftest import RequestStatus -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @@ -426,3 +428,80 @@ async def test_dhcp_preserves_existing_mac( assert entry.data[CONF_HOST] == "192.168.0.123" # IP was updated assert entry.data[CONF_MAC] == "existing11aabb" # MAC remains unchanged assert entry.data[CONF_MAC] != "different22ccdd" # Not updated to new MAC + + +async def _start_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, host_ip: str +) -> Any: + """Initialize a reconfigure flow for PoolDose and submit new host.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + + return await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], {CONF_HOST: host_ip} + ) + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test successful reconfigure updates host and reloads entry.""" + # Ensure the mocked device returns the same serial number as the + # config entry so the reconfigure flow matches the device + mock_pooldose_client.device_info = {"SERIAL_NUMBER": mock_config_entry.unique_id} + + result = await _start_reconfigure_flow(hass, mock_config_entry, "192.168.0.200") + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Config entry should have updated host + assert mock_config_entry.data.get(CONF_HOST) == "192.168.0.200" + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Config entry should have updated host + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry is not None + assert entry.data.get(CONF_HOST) == "192.168.0.200" + + +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure shows cannot_connect when device unreachable.""" + mock_pooldose_client.connect.return_value = RequestStatus.HOST_UNREACHABLE + + result = await _start_reconfigure_flow(hass, mock_config_entry, "192.168.0.200") + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_reconfigure_flow_wrong_device( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure aborts when serial number doesn't match existing entry.""" + # Return device info with different serial number + mock_pooldose_client.device_info = {"SERIAL_NUMBER": "OTHER123"} + + result = await _start_reconfigure_flow(hass, mock_config_entry, "192.168.0.200") + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index a766846175a755..ea9975ad68304c 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -185,6 +185,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.smart_ai_type_list.return_value = ["people"] host_mock.baichuan.smart_ai_index.return_value = 1 host_mock.baichuan.smart_ai_name.return_value = "zone1" + host_mock.whiteled_brightness.return_value = None def ai_detect_type(channel: int, object_type: str) -> str | None: if object_type == "people": diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index a9c2d8cc1bfa76..0896c1df87d559 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -74,6 +74,7 @@ async def test_light_turn_off( ) -> None: """Test light turn off service.""" reolink_host.whiteled_color_temperature.return_value = 3000 + reolink_host.whiteled_brightness.return_value = 75 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -81,6 +82,8 @@ async def test_light_turn_off( assert config_entry.state is ConfigEntryState.LOADED entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" + state = hass.states.get(entity_id) + assert state and state.attributes.get(ATTR_BRIGHTNESS) == 191 await hass.services.async_call( LIGHT_DOMAIN, @@ -107,6 +110,7 @@ async def test_light_turn_on( ) -> None: """Test light turn on service.""" reolink_host.whiteled_color_temperature.return_value = 3000 + reolink_host.whiteled_brightness.return_value = None with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/sonos/test_services.py b/tests/components/sonos/test_services.py index 3bc85d3b166253..7e30e1e2125d53 100644 --- a/tests/components/sonos/test_services.py +++ b/tests/components/sonos/test_services.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import MockSoCo, group_speakers, ungroup_speakers +from .conftest import MockSoCo, create_zgs_sonos_event, group_speakers, ungroup_speakers async def test_media_player_join( @@ -134,6 +134,7 @@ async def test_media_player_join_timeout( "Timeout while waiting for Sonos player to join the " "group Living Room: Living Room, Bedroom" ) + with ( patch( "homeassistant.components.sonos.speaker.asyncio.timeout", instant_timeout @@ -247,3 +248,65 @@ async def test_media_player_unjoin_already_unjoined( # Should not have called unjoin, since the speakers are already unjoined. assert soco_bedroom.unjoin.call_count == 0 assert soco_living_room.unjoin.call_count == 0 + + +async def test_unjoin_completes_when_coordinator_receives_event_first( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that unjoin completes even when only coordinator receives ZGS event.""" + soco_living_room = sonos_setup_two_speakers[0] + soco_bedroom = sonos_setup_two_speakers[1] + + # First, group the speakers together + group_speakers(soco_living_room, soco_bedroom) + await hass.async_block_till_done(wait_background_tasks=True) + + # Verify initial grouped state + expected_group = ["media_player.living_room", "media_player.bedroom"] + assert ( + hass.states.get("media_player.living_room").attributes["group_members"] + == expected_group + ) + assert ( + hass.states.get("media_player.bedroom").attributes["group_members"] + == expected_group + ) + + unjoin_complete_event = asyncio.Event() + + def mock_unjoin(*args, **kwargs) -> None: + hass.loop.call_soon_threadsafe(unjoin_complete_event.set) + + soco_bedroom.unjoin = Mock(side_effect=mock_unjoin) + + with caplog.at_level(logging.WARNING): + caplog.clear() + await hass.services.async_call( + MP_DOMAIN, + SERVICE_UNJOIN, + {ATTR_ENTITY_ID: "media_player.bedroom"}, + blocking=False, + ) + await unjoin_complete_event.wait() + + # Fire ZGS event only to coordinator to test clearing of bedroom speaker + ungroup_event = create_zgs_sonos_event( + "zgs_two_single.xml", + soco_living_room, + soco_bedroom, + create_uui_ds_in_group=False, + ) + soco_living_room.zoneGroupTopology.subscribe.return_value._callback( + ungroup_event + ) + await hass.async_block_till_done(wait_background_tasks=True) + + # Should complete without warnings or timeout errors + assert len(caplog.records) == 0 + assert soco_bedroom.unjoin.call_count == 1 + state = hass.states.get("media_player.living_room") + assert state.attributes["group_members"] == ["media_player.living_room"] + state = hass.states.get("media_player.bedroom") + assert state.attributes["group_members"] == ["media_player.bedroom"] diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index 040cdf5d9ed51b..8be8eadbb26a15 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -346,9 +346,9 @@ async def test_instant_arming_exceptions( (ArmingState.ARMED_STAY_PROA7, AlarmControlPanelState.ARMED_HOME), (ArmingState.ARMED_STAY_BYPASS, AlarmControlPanelState.ARMED_HOME), (ArmingState.ARMED_STAY_BYPASS_PROA7, AlarmControlPanelState.ARMED_HOME), - (ArmingState.ARMED_STAY_INSTANT, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_INSTANT, AlarmControlPanelState.ARMED_NIGHT), (ArmingState.ARMED_STAY_INSTANT_PROA7, AlarmControlPanelState.ARMED_HOME), - (ArmingState.ARMED_STAY_INSTANT_BYPASS, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_INSTANT_BYPASS, AlarmControlPanelState.ARMED_NIGHT), ( ArmingState.ARMED_STAY_INSTANT_BYPASS_PROA7, AlarmControlPanelState.ARMED_HOME,