diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 93244068feb3e..6487830675fc6 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -118,7 +118,6 @@ async def async_get_config_entry_diagnostics( device_dict.pop("_cache", None) # This can be removed when suggested_area is removed from DeviceEntry device_dict.pop("_suggested_area") - device_dict.pop("is_new", None) device_entities.append({"device": device_dict, "entities": entities}) # remove envoy serial diff --git a/homeassistant/components/google_cloud/__init__.py b/homeassistant/components/google_cloud/__init__.py index 9d1923fd87da8..3fc225ad4232c 100644 --- a/homeassistant/components/google_cloud/__init__.py +++ b/homeassistant/components/google_cloud/__init__.py @@ -12,15 +12,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_update_options)) return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/google_cloud/config_flow.py b/homeassistant/components/google_cloud/config_flow.py index fa6c952022b51..34a42bd8b8551 100644 --- a/homeassistant/components/google_cloud/config_flow.py +++ b/homeassistant/components/google_cloud/config_flow.py @@ -15,7 +15,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.core import callback from homeassistant.helpers.selector import ( @@ -138,7 +138,7 @@ def async_get_options_flow( return GoogleCloudOptionsFlowHandler() -class GoogleCloudOptionsFlowHandler(OptionsFlow): +class GoogleCloudOptionsFlowHandler(OptionsFlowWithReload): """Google Cloud options flow.""" async def async_step_init( diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py index ee04dd8108831..00f77189e2b8b 100644 --- a/homeassistant/components/govee_light_local/__init__.py +++ b/homeassistant/components/govee_light_local/__init__.py @@ -9,6 +9,7 @@ from govee_local_api.controller import LISTENING_PORT +from homeassistant.components import network from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -23,12 +24,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool: """Set up Govee light local from a config entry.""" - coordinator = GoveeLocalApiCoordinator(hass, entry) + + # Get source IPs for all enabled adapters + source_ips = await network.async_get_enabled_source_ips(hass) + _LOGGER.debug("Enabled source IPs: %s", source_ips) + + coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator( + hass=hass, config_entry=entry, source_ips=source_ips + ) async def await_cleanup(): - cleanup_complete: asyncio.Event = coordinator.cleanup() + cleanup_complete_events: [asyncio.Event] = coordinator.cleanup() with suppress(TimeoutError): - await asyncio.wait_for(cleanup_complete.wait(), 1) + await asyncio.gather( + *[ + asyncio.wait_for(cleanup_complete_event.wait(), 1) + for cleanup_complete_event in cleanup_complete_events + ] + ) entry.async_on_unload(await_cleanup) diff --git a/homeassistant/components/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py index da70d44688b3c..67fa4b548cdd7 100644 --- a/homeassistant/components/govee_light_local/config_flow.py +++ b/homeassistant/components/govee_light_local/config_flow.py @@ -4,6 +4,7 @@ import asyncio from contextlib import suppress +from ipaddress import IPv4Address, IPv6Address import logging from govee_local_api import GoveeController @@ -23,15 +24,13 @@ _LOGGER = logging.getLogger(__name__) -async def _async_has_devices(hass: HomeAssistant) -> bool: - """Return if there are devices that can be discovered.""" - - adapter = await network.async_get_source_ip(hass, network.PUBLIC_TARGET_IP) - +async def _async_discover( + hass: HomeAssistant, adapter_ip: IPv4Address | IPv6Address +) -> bool: controller: GoveeController = GoveeController( loop=hass.loop, logger=_LOGGER, - listening_address=adapter, + listening_address=str(adapter_ip), broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, broadcast_port=CONF_TARGET_PORT_DEFAULT, listening_port=CONF_LISTENING_PORT_DEFAULT, @@ -41,9 +40,10 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: ) try: + _LOGGER.debug("Starting discovery with IP %s", adapter_ip) await controller.start() except OSError as ex: - _LOGGER.error("Start failed, errno: %d", ex.errno) + _LOGGER.error("Start failed on IP %s, errno: %d", adapter_ip, ex.errno) return False try: @@ -51,16 +51,34 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: while not controller.devices: await asyncio.sleep(delay=1) except TimeoutError: - _LOGGER.debug("No devices found") + _LOGGER.debug("No devices found with IP %s", adapter_ip) devices_count = len(controller.devices) - cleanup_complete: asyncio.Event = controller.cleanup() + cleanup_complete_events: list[asyncio.Event] = [] with suppress(TimeoutError): - await asyncio.wait_for(cleanup_complete.wait(), 1) + await asyncio.gather( + *[ + asyncio.wait_for(cleanup_complete_event.wait(), 1) + for cleanup_complete_event in cleanup_complete_events + ] + ) return devices_count > 0 +async def _async_has_devices(hass: HomeAssistant) -> bool: + """Return if there are devices that can be discovered.""" + + # Get source IPs for all enabled adapters + source_ips = await network.async_get_enabled_source_ips(hass) + _LOGGER.debug("Enabled source IPs: %s", source_ips) + + # Run discovery on every IPv4 address and gather results + results = await asyncio.gather(*[_async_discover(hass, ip) for ip in source_ips]) + + return any(results) + + config_entry_flow.register_discovery_flow( DOMAIN, "Govee light local", _async_has_devices ) diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py index 530ade1f74308..9e0792a132dce 100644 --- a/homeassistant/components/govee_light_local/coordinator.py +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Callable +from ipaddress import IPv4Address, IPv6Address import logging from govee_local_api import GoveeController, GoveeDevice @@ -11,7 +12,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( - CONF_DISCOVERY_INTERVAL_DEFAULT, CONF_LISTENING_PORT_DEFAULT, CONF_MULTICAST_ADDRESS_DEFAULT, CONF_TARGET_PORT_DEFAULT, @@ -26,10 +26,11 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]): """Govee light local coordinator.""" - config_entry: GoveeLocalConfigEntry - def __init__( - self, hass: HomeAssistant, config_entry: GoveeLocalConfigEntry + self, + hass: HomeAssistant, + config_entry: GoveeLocalConfigEntry, + source_ips: list[IPv4Address | IPv6Address], ) -> None: """Initialize my coordinator.""" super().__init__( @@ -40,32 +41,40 @@ def __init__( update_interval=SCAN_INTERVAL, ) - self._controller = GoveeController( - loop=hass.loop, - logger=_LOGGER, - broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, - broadcast_port=CONF_TARGET_PORT_DEFAULT, - listening_port=CONF_LISTENING_PORT_DEFAULT, - discovery_enabled=True, - discovery_interval=CONF_DISCOVERY_INTERVAL_DEFAULT, - discovered_callback=None, - update_enabled=False, - ) + self._controllers: list[GoveeController] = [ + GoveeController( + loop=hass.loop, + logger=_LOGGER, + listening_address=str(source_ip), + broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, + broadcast_port=CONF_TARGET_PORT_DEFAULT, + listening_port=CONF_LISTENING_PORT_DEFAULT, + discovery_enabled=True, + discovery_interval=1, + update_enabled=False, + ) + for source_ip in source_ips + ] async def start(self) -> None: """Start the Govee coordinator.""" - await self._controller.start() - self._controller.send_update_message() + + for controller in self._controllers: + await controller.start() + controller.send_update_message() async def set_discovery_callback( self, callback: Callable[[GoveeDevice, bool], bool] ) -> None: """Set discovery callback for automatic Govee light discovery.""" - self._controller.set_device_discovered_callback(callback) - def cleanup(self) -> asyncio.Event: - """Stop and cleanup the cooridinator.""" - return self._controller.cleanup() + for controller in self._controllers: + controller.set_device_discovered_callback(callback) + + def cleanup(self) -> list[asyncio.Event]: + """Stop and cleanup the coordinator.""" + + return [controller.cleanup() for controller in self._controllers] async def turn_on(self, device: GoveeDevice) -> None: """Turn on the light.""" @@ -96,8 +105,14 @@ async def set_scene(self, device: GoveeController, scene: str) -> None: @property def devices(self) -> list[GoveeDevice]: """Return a list of discovered Govee devices.""" - return self._controller.devices + + devices: list[GoveeDevice] = [] + for controller in self._controllers: + devices = devices + controller.devices + return devices async def _async_update_data(self) -> list[GoveeDevice]: - self._controller.send_update_message() - return self._controller.devices + for controller in self._controllers: + controller.send_update_message() + + return self.devices diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index cde2812b0595f..13c52f04a0602 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -49,7 +49,7 @@ "Bancs de Brouillard", "Brouillard dense", ], - ATTR_CONDITION_HAIL: ["Risque de grêle", "Risque de grèle"], + ATTR_CONDITION_HAIL: ["Risque de grêle", "Risque de grèle", "Averses de grèle"], ATTR_CONDITION_LIGHTNING: ["Risque d'orages", "Orages", "Orage avec grêle"], ATTR_CONDITION_LIGHTNING_RAINY: [ "Pluie orageuses", diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 5e5f50c96fc5a..8364b3574ae36 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -349,8 +349,6 @@ class DeviceEntry: _suggested_area: str | None = attr.ib(default=None) sw_version: str | None = attr.ib(default=None) via_device_id: str | None = attr.ib(default=None) - # This value is not stored, just used to keep track of events to fire. - is_new: bool = attr.ib(default=False) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @property @@ -499,7 +497,6 @@ def to_device_entry( disabled_by=disabled_by, identifiers=self.identifiers & identifiers, # type: ignore[arg-type] id=self.id, - is_new=True, labels=self.labels, # type: ignore[arg-type] name_by_user=self.name_by_user, ) @@ -910,7 +907,11 @@ def async_get_or_create( identifiers=identifiers, connections=connections ) + is_new = False + if device is None: + is_new = True + deleted_device = self.deleted_devices.get_entry(identifiers, connections) if deleted_device is None: area_id: str | None = None @@ -924,7 +925,7 @@ def async_get_or_create( area = ar.async_get(self.hass).async_get_or_create(suggested_area) area_id = area.id - device = DeviceEntry(is_new=True, area_id=area_id) + device = DeviceEntry(area_id=area_id) else: self.deleted_devices.pop(deleted_device.id) @@ -935,6 +936,7 @@ def async_get_or_create( connections, identifiers, ) + self.devices[device.id] = device # If creating a new device, default to the config entry name if device_info_type == "primary" and (not name or name is UNDEFINED): @@ -963,7 +965,7 @@ def async_get_or_create( else: via_device_id = UNDEFINED - device = self.async_update_device( + device = self._async_update_device( device.id, allow_collisions=True, add_config_entry_id=config_entry_id, @@ -973,6 +975,7 @@ def async_get_or_create( disabled_by=disabled_by, entry_type=entry_type, hw_version=hw_version, + is_new=is_new, manufacturer=manufacturer, merge_connections=connections or UNDEFINED, merge_identifiers=identifiers or UNDEFINED, @@ -980,7 +983,7 @@ def async_get_or_create( model_id=model_id, name=name, serial_number=serial_number, - _suggested_area=suggested_area, + suggested_area=suggested_area, sw_version=sw_version, via_device_id=via_device_id, ) @@ -991,14 +994,14 @@ def async_get_or_create( return device @callback - def async_update_device( # noqa: C901 + def _async_update_device( # noqa: C901 self, device_id: str, *, add_config_entry_id: str | UndefinedType = UNDEFINED, add_config_subentry_id: str | None | UndefinedType = UNDEFINED, # Temporary flag so we don't blow up when collisions are implicitly introduced - # by calls to async_get_or_create. Must not be set by integrations. + # by calls to async_get_or_create. allow_collisions: bool = False, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, @@ -1006,6 +1009,7 @@ def async_update_device( # noqa: C901 disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, + is_new: bool = False, labels: set[str] | UndefinedType = UNDEFINED, manufacturer: str | None | UndefinedType = UNDEFINED, merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, @@ -1019,15 +1023,12 @@ def async_update_device( # noqa: C901 remove_config_entry_id: str | UndefinedType = UNDEFINED, remove_config_subentry_id: str | None | UndefinedType = UNDEFINED, serial_number: str | None | UndefinedType = UNDEFINED, - # _suggested_area is used internally by the device registry and must - # not be set by integrations. - _suggested_area: str | None | UndefinedType = UNDEFINED, - # suggested_area is deprecated and will be removed in 2026.9 + # Can be removed when suggested_area is removed from DeviceEntry suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device_id: str | None | UndefinedType = UNDEFINED, ) -> DeviceEntry | None: - """Update device attributes. + """Private update device attributes. :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id @@ -1191,16 +1192,6 @@ def async_update_device( # noqa: C901 new_values["config_entries_subentries"] = config_entries_subentries old_values["config_entries_subentries"] = old.config_entries_subentries - if suggested_area is not UNDEFINED: - report_usage( - "passes a suggested_area to device_registry.async_update device", - core_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2026.9.0", - ) - - if _suggested_area is not UNDEFINED: - suggested_area = _suggested_area - added_connections: set[tuple[str, str]] | None = None added_identifiers: set[tuple[str, str]] | None = None @@ -1266,10 +1257,7 @@ def async_update_device( # noqa: C901 new_values["suggested_area"] = suggested_area old_values["suggested_area"] = old._suggested_area # noqa: SLF001 - if old.is_new: - new_values["is_new"] = False - - if not new_values: + if not new_values and not is_new: return old # This condition can be removed when suggested_area is removed from DeviceEntry @@ -1301,7 +1289,7 @@ def async_update_device( # noqa: C901 self.async_schedule_save() data: EventDeviceRegistryUpdatedData - if old.is_new: + if is_new: data = {"action": "create", "device_id": new.id} else: data = {"action": "update", "device_id": new.id, "changes": old_values} @@ -1310,6 +1298,77 @@ def async_update_device( # noqa: C901 return new + @callback + def async_update_device( + self, + device_id: str, + *, + add_config_entry_id: str | UndefinedType = UNDEFINED, + add_config_subentry_id: str | None | UndefinedType = UNDEFINED, + area_id: str | None | UndefinedType = UNDEFINED, + configuration_url: str | URL | None | UndefinedType = UNDEFINED, + device_info_type: str | UndefinedType = UNDEFINED, + disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, + entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, + hw_version: str | None | UndefinedType = UNDEFINED, + labels: set[str] | UndefinedType = UNDEFINED, + manufacturer: str | None | UndefinedType = UNDEFINED, + merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, + merge_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, + model: str | None | UndefinedType = UNDEFINED, + model_id: str | None | UndefinedType = UNDEFINED, + name_by_user: str | None | UndefinedType = UNDEFINED, + name: str | None | UndefinedType = UNDEFINED, + new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, + new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, + remove_config_entry_id: str | UndefinedType = UNDEFINED, + remove_config_subentry_id: str | None | UndefinedType = UNDEFINED, + serial_number: str | None | UndefinedType = UNDEFINED, + # suggested_area is deprecated and will be removed in 2026.9 + suggested_area: str | None | UndefinedType = UNDEFINED, + sw_version: str | None | UndefinedType = UNDEFINED, + via_device_id: str | None | UndefinedType = UNDEFINED, + ) -> DeviceEntry | None: + """Update device attributes. + + :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id + :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id + """ + if suggested_area is not UNDEFINED: + report_usage( + "passes a suggested_area to device_registry.async_update device", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.9.0", + ) + + return self._async_update_device( + device_id, + add_config_entry_id=add_config_entry_id, + add_config_subentry_id=add_config_subentry_id, + area_id=area_id, + configuration_url=configuration_url, + device_info_type=device_info_type, + disabled_by=disabled_by, + entry_type=entry_type, + hw_version=hw_version, + labels=labels, + manufacturer=manufacturer, + merge_connections=merge_connections, + merge_identifiers=merge_identifiers, + model=model, + model_id=model_id, + name_by_user=name_by_user, + name=name, + new_connections=new_connections, + new_identifiers=new_identifiers, + remove_config_entry_id=remove_config_entry_id, + remove_config_subentry_id=remove_config_subentry_id, + serial_number=serial_number, + suggested_area=suggested_area, + sw_version=sw_version, + via_device_id=via_device_id, + ) + @callback def _validate_connections( self, diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 86c428b4413cb..166fd1a9e652a 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -330,7 +330,6 @@ async def test_snapshots( device_dict.pop("_cache", None) # This can be removed when suggested_area is removed from DeviceEntry device_dict.pop("_suggested_area") - device_dict.pop("is_new") devices.append({"device": device_dict, "entities": entities}) diff --git a/tests/syrupy.py b/tests/syrupy.py index 919ba1a6cea6f..642e5a519b2bd 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -175,7 +175,6 @@ def _serializable_device_registry_entry( serialized.pop("_cache") # This can be removed when suggested_area is removed from DeviceEntry serialized.pop("_suggested_area") - serialized.pop("is_new") return cls._remove_created_and_modified_at(serialized) @classmethod