Repository: home-assistant/core
Pull request #142309: Kulersky refactor to new Bluetooth subsystem
Author: login: emlove
Labels: name: cla-signed
name: new-feature
name: has-tests
name: by-code-owner
name: integration: kulersky
name: smash
description: Indicator this PR is close to finish for merging or closing
name: Quality Scale: No score
Created: 2025-04-04T23:39:53Z | Merged: 2025-04-14T13:38:34Z

## Proposed change
👋🏻 It's been a while!

This PR updates the kulersky component to use the core Bluetooth system instead of setting up its own Bleak scanner. This means that these devices can now be automatically discovered!

Upstream changelog: https://github.com/emlove/pykulersky?tab=readme-ov-file#changelog

The biggest upstream change is that it now allows a BLEDevice to be passed down to the underlying BleakClient so that it can take advantage of the BLEDevice from the hass bluetooth component.

## Type of change
- [ ] Dependency upgrade
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New integration (thank you!)
- [x] New feature (which adds functionality to an existing integration)
- [ ] Deprecation (breaking change to happen in the future)
- [ ] Breaking change (fix/feature causing existing functionality to break)
- [ ] Code quality improvements to existing code or addition of tests

## Additional information
<!--
  Details are important, and help maintainers processing your PR.
  Please be sure to fill out additional details, if applicable.
-->

- This PR fixes or closes issue: fixes #
- This PR is related to issue: 
- Link to documentation pull request: https://github.com/home-assistant/home-assistant.io/pull/38390
- Link to developer documentation pull request: 
- Link to frontend pull request: 

## Checklist
<!--
  Put an `x` in the boxes that apply. You can also fill these out after
  creating the PR. If you're unsure about any of them, don't hesitate to ask.
  We're here to help! This is simply a reminder of what we are going to look
  for before merging your code.
-->

- [x] The code change is tested and works locally.
- [x] Local tests pass. **Your PR cannot be merged unless tests pass**
- [x] There is no commented out code in this PR.
- [x] I have followed the [development checklist][dev-checklist]
- [x] I have followed the [perfect PR recommendations][perfect-pr]
- [x] The code has been formatted using Ruff (`ruff format homeassistant tests`)
- [x] Tests have been added to verify that the new code works.

If user exposed functionality or configuration variables are added/changed:

- [x] Documentation added/updated for [www.home-assistant.io][docs-repository]

If the code communicates with devices, web services, or third-party tools:

- [x] The [manifest file][manifest-docs] has all fields filled out correctly.  
      Updated and included derived files by running: `python3 -m script.hassfest`.
- [x] New or updated dependencies have been added to `requirements_all.txt`.  
      Updated by running `python3 -m script.gen_requirements_all`.
- [x] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.

<!--
  This project is very active and we have a high turnover of pull requests.

  Unfortunately, the number of incoming pull requests is higher than what our
  reviewers can review and merge so there is a long backlog of pull requests
  waiting for review. You can help here!
  
  By reviewing another pull request, you will help raise the code quality of
  that pull request and the final review will be faster. This way the general
  pace of pull request reviews will go up and your wait time will go down.
  
  When picking a pull request to review, try to choose one that hasn't yet
  been reviewed.

  Thanks for helping out!
-->

To help with the load of incoming pull requests:

- [ ] I have reviewed two other [open pull requests][prs] in this repository.

[prs]: https://github.com/home-assistant/core/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+-label%3Awaiting-for-upstream+sort%3Acreated-desc+review%3Anone+-status%3Afailure

<!--
  Thank you for contributing <3

  Below, some useful links you could explore:
-->
[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/
[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/
[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr



Diff:
diff --git a/.strict-typing b/.strict-typing
index 3e8ad0ddbaf2e3..69d469588824b1 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -291,6 +291,7 @@ homeassistant.components.kaleidescape.*
 homeassistant.components.knocki.*
 homeassistant.components.knx.*
 homeassistant.components.kraken.*
+homeassistant.components.kulersky.*
 homeassistant.components.lacrosse.*
 homeassistant.components.lacrosse_view.*
 homeassistant.components.lamarzocco.*
diff --git a/homeassistant/components/kulersky/__init__.py b/homeassistant/components/kulersky/__init__.py
index 6c8037bdafc010..b123a4cc035e75 100644
--- a/homeassistant/components/kulersky/__init__.py
+++ b/homeassistant/components/kulersky/__init__.py
@@ -1,21 +1,31 @@
 """Kuler Sky lights integration."""
 
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
+import logging
+
+from homeassistant.components.bluetooth import async_ble_device_from_address
+from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
+from homeassistant.const import CONF_ADDRESS, Platform
 from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
 
-from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
+from .const import DOMAIN
 
 PLATFORMS = [Platform.LIGHT]
 
+_LOGGER = logging.getLogger(__name__)
+
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Set up Kuler Sky from a config entry."""
-    if DOMAIN not in hass.data:
-        hass.data[DOMAIN] = {}
-    if DATA_ADDRESSES not in hass.data[DOMAIN]:
-        hass.data[DOMAIN][DATA_ADDRESSES] = set()
-
+    ble_device = async_ble_device_from_address(
+        hass, entry.data[CONF_ADDRESS], connectable=True
+    )
+    if not ble_device:
+        raise ConfigEntryNotReady(
+            translation_domain=DOMAIN,
+            translation_key="cannot_connect",
+        )
     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 
     return True
@@ -23,11 +33,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 
 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Unload a config entry."""
-    # Stop discovery
-    unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None)
-    if unregister_discovery:
-        unregister_discovery()
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 
-    hass.data.pop(DOMAIN, None)
 
-    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+    """Migrate old entry."""
+    _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+    # Version 1 was a single entry instance that started a bluetooth discovery
+    # thread to add devices. Version 2 has one config entry per device, and
+    # supports core bluetooth discovery
+    if config_entry.version == 1:
+        dev_reg = dr.async_get(hass)
+        devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
+
+        if len(devices) == 0:
+            _LOGGER.error("Unable to migrate; No devices registered")
+            return False
+
+        first_device = devices[0]
+        domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN]
+        address = next(iter(domain_identifiers))[1]
+        hass.config_entries.async_update_entry(
+            config_entry,
+            title=first_device.name or address,
+            data={CONF_ADDRESS: address},
+            unique_id=address,
+            version=2,
+        )
+
+        # Create new config flows for the remaining devices
+        for device in devices[1:]:
+            domain_identifiers = [i for i in device.identifiers if i[0] == DOMAIN]
+            address = next(iter(domain_identifiers))[1]
+
+            hass.async_create_task(
+                hass.config_entries.flow.async_init(
+                    DOMAIN,
+                    context={"source": SOURCE_INTEGRATION_DISCOVERY},
+                    data={CONF_ADDRESS: address},
+                )
+            )
+
+    _LOGGER.debug("Migration to version %s successful", config_entry.version)
+
+    return True
diff --git a/homeassistant/components/kulersky/config_flow.py b/homeassistant/components/kulersky/config_flow.py
index fca214dd9a383e..f27d2ef0ea03bd 100644
--- a/homeassistant/components/kulersky/config_flow.py
+++ b/homeassistant/components/kulersky/config_flow.py
@@ -1,26 +1,143 @@
 """Config flow for Kuler Sky."""
 
 import logging
+from typing import Any
 
+from bluetooth_data_tools import human_readable_name
 import pykulersky
+import voluptuous as vol
 
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
+from homeassistant.components.bluetooth import (
+    BluetoothServiceInfoBleak,
+    async_discovered_service_info,
+    async_last_service_info,
+)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ADDRESS
 
-from .const import DOMAIN
+from .const import DOMAIN, EXPECTED_SERVICE_UUID
 
 _LOGGER = logging.getLogger(__name__)
 
 
-async def _async_has_devices(hass: HomeAssistant) -> bool:
-    """Return if there are devices that can be discovered."""
-    # Check if there are any devices that can be discovered in the network.
-    try:
-        devices = await pykulersky.discover()
-    except pykulersky.PykulerskyException as exc:
-        _LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc)
-        return False
-    return len(devices) > 0
+class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Kulersky."""
 
+    VERSION = 2
 
-config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices)
+    def __init__(self) -> None:
+        """Initialize the config flow."""
+        self._discovery_info: BluetoothServiceInfoBleak | None = None
+        self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
+
+    async def async_step_integration_discovery(
+        self, discovery_info: dict[str, str]
+    ) -> ConfigFlowResult:
+        """Handle the integration discovery step.
+
+        The old version of the integration used to have multiple
+        device in a single config entry. This is now deprecated.
+        The integration discovery step is used to create config
+        entries for each device beyond the first one.
+        """
+        address: str = discovery_info[CONF_ADDRESS]
+        if service_info := async_last_service_info(self.hass, address):
+            title = human_readable_name(None, service_info.name, service_info.address)
+        else:
+            title = address
+        await self.async_set_unique_id(address)
+        self._abort_if_unique_id_configured()
+        return self.async_create_entry(
+            title=title,
+            data={CONF_ADDRESS: address},
+        )
+
+    async def async_step_bluetooth(
+        self, discovery_info: BluetoothServiceInfoBleak
+    ) -> ConfigFlowResult:
+        """Handle the bluetooth discovery step."""
+        await self.async_set_unique_id(discovery_info.address)
+        self._abort_if_unique_id_configured()
+        self._discovery_info = discovery_info
+        self.context["title_placeholders"] = {
+            "name": human_readable_name(
+                None, discovery_info.name, discovery_info.address
+            )
+        }
+        return await self.async_step_user()
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the user step to pick discovered device."""
+        errors: dict[str, str] = {}
+
+        if user_input is not None:
+            address = user_input[CONF_ADDRESS]
+            discovery_info = self._discovered_devices[address]
+            local_name = human_readable_name(
+                None, discovery_info.name, discovery_info.address
+            )
+            await self.async_set_unique_id(
+                discovery_info.address, raise_on_progress=False
+            )
+            self._abort_if_unique_id_configured()
+
+            kulersky_light = None
+            try:
+                kulersky_light = pykulersky.Light(discovery_info.address)
+                await kulersky_light.connect()
+            except pykulersky.PykulerskyException:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected error")
+                errors["base"] = "unknown"
+            else:
+                return self.async_create_entry(
+                    title=local_name,
+                    data={
+                        CONF_ADDRESS: discovery_info.address,
+                    },
+                )
+            finally:
+                if kulersky_light:
+                    await kulersky_light.disconnect()
+
+        if discovery := self._discovery_info:
+            self._discovered_devices[discovery.address] = discovery
+        else:
+            current_addresses = self._async_current_ids()
+            for discovery in async_discovered_service_info(self.hass):
+                if (
+                    discovery.address in current_addresses
+                    or discovery.address in self._discovered_devices
+                    or EXPECTED_SERVICE_UUID not in discovery.service_uuids
+                ):
+                    continue
+                self._discovered_devices[discovery.address] = discovery
+
+        if not self._discovered_devices:
+            return self.async_abort(reason="no_devices_found")
+
+        if self._discovery_info:
+            data_schema = vol.Schema(
+                {vol.Required(CONF_ADDRESS): self._discovery_info.address}
+            )
+        else:
+            data_schema = vol.Schema(
+                {
+                    vol.Required(CONF_ADDRESS): vol.In(
+                        {
+                            service_info.address: (
+                                f"{service_info.name} ({service_info.address})"
+                            )
+                            for service_info in self._discovered_devices.values()
+                        }
+                    ),
+                }
+            )
+        return self.async_show_form(
+            step_id="user",
+            data_schema=data_schema,
+            errors=errors,
+        )
diff --git a/homeassistant/components/kulersky/const.py b/homeassistant/components/kulersky/const.py
index 8d0b4380bb3059..c735b4774f9195 100644
--- a/homeassistant/components/kulersky/const.py
+++ b/homeassistant/components/kulersky/const.py
@@ -4,3 +4,5 @@
 
 DATA_ADDRESSES = "addresses"
 DATA_DISCOVERY_SUBSCRIPTION = "discovery_subscription"
+
+EXPECTED_SERVICE_UUID = "8d96a001-0002-64c2-0001-9acc4838521c"
diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py
index bcc3f32dceb1a9..d6a45ed1ebe4a0 100644
--- a/homeassistant/components/kulersky/light.py
+++ b/homeassistant/components/kulersky/light.py
@@ -2,12 +2,12 @@
 
 from __future__ import annotations
 
-from datetime import timedelta
 import logging
 from typing import Any
 
 import pykulersky
 
+from homeassistant.components import bluetooth
 from homeassistant.components.light import (
     ATTR_BRIGHTNESS,
     ATTR_RGBW_COLOR,
@@ -15,18 +15,15 @@
     LightEntity,
 )
 from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.const import CONF_ADDRESS
 from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
 from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from homeassistant.helpers.event import async_track_time_interval
 
-from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
+from .const import DOMAIN
 
 _LOGGER = logging.getLogger(__name__)
 
-DISCOVERY_INTERVAL = timedelta(seconds=60)
-
 
 async def async_setup_entry(
     hass: HomeAssistant,
@@ -34,32 +31,15 @@ async def async_setup_entry(
     async_add_entities: AddConfigEntryEntitiesCallback,
 ) -> None:
     """Set up Kuler sky light devices."""
-
-    async def discover(*args):
-        """Attempt to discover new lights."""
-        lights = await pykulersky.discover()
-
-        # Filter out already discovered lights
-        new_lights = [
-            light
-            for light in lights
-            if light.address not in hass.data[DOMAIN][DATA_ADDRESSES]
-        ]
-
-        new_entities = []
-        for light in new_lights:
-            hass.data[DOMAIN][DATA_ADDRESSES].add(light.address)
-            new_entities.append(KulerskyLight(light))
-
-        async_add_entities(new_entities, update_before_add=True)
-
-    # Start initial discovery
-    hass.async_create_task(discover())
-
-    # Perform recurring discovery of new devices
-    hass.data[DOMAIN][DATA_DISCOVERY_SUBSCRIPTION] = async_track_time_interval(
-        hass, discover, DISCOVERY_INTERVAL
+    ble_device = bluetooth.async_ble_device_from_address(
+        hass, config_entry.data[CONF_ADDRESS], connectable=True
+    )
+    entity = KulerskyLight(
+        config_entry.title,
+        config_entry.data[CONF_ADDRESS],
+        pykulersky.Light(ble_device),
     )
+    async_add_entities([entity], update_before_add=True)
 
 
 class KulerskyLight(LightEntity):
@@ -71,37 +51,30 @@ class KulerskyLight(LightEntity):
     _attr_supported_color_modes = {ColorMode.RGBW}
     _attr_color_mode = ColorMode.RGBW
 
-    def __init__(self, light: pykulersky.Light) -> None:
+    def __init__(self, name: str, address: str, light: pykulersky.Light) -> None:
         """Initialize a Kuler Sky light."""
         self._light = light
-        self._attr_unique_id = light.address
+        self._attr_unique_id = address
         self._attr_device_info = DeviceInfo(
-            identifiers={(DOMAIN, light.address)},
+            identifiers={(DOMAIN, address)},
+            connections={(CONNECTION_BLUETOOTH, address)},
             manufacturer="Brightech",
-            name=light.name,
-        )
-
-    async def async_added_to_hass(self) -> None:
-        """Run when entity about to be added to hass."""
-        self.async_on_remove(
-            self.hass.bus.async_listen_once(
-                EVENT_HOMEASSISTANT_STOP, self.async_will_remove_from_hass
-            )
+            name=name,
         )
 
-    async def async_will_remove_from_hass(self, *args) -> None:
+    async def async_will_remove_from_hass(self) -> None:
         """Run when entity will be removed from hass."""
         try:
             await self._light.disconnect()
         except pykulersky.PykulerskyException:
             _LOGGER.debug(
-                "Exception disconnected from %s", self._light.address, exc_info=True
+                "Exception disconnected from %s", self._attr_unique_id, exc_info=True
             )
 
     @property
-    def is_on(self):
+    def is_on(self) -> bool | None:
         """Return true if light is on."""
-        return self.brightness > 0
+        return self.brightness is not None and self.brightness > 0
 
     async def async_turn_on(self, **kwargs: Any) -> None:
         """Instruct the light to turn on."""
@@ -133,11 +106,13 @@ async def async_update(self) -> None:
             rgbw = await self._light.get_color()
         except pykulersky.PykulerskyException as exc:
             if self._attr_available:
-                _LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc)
+                _LOGGER.warning(
+                    "Unable to connect to %s: %s", self._attr_unique_id, exc
+                )
             self._attr_available = False
             return
         if self._attr_available is False:
-            _LOGGER.warning("Reconnected to %s", self._light.address)
+            _LOGGER.info("Reconnected to %s", self._attr_unique_id)
 
         self._attr_available = True
         brightness = max(rgbw)
diff --git a/homeassistant/components/kulersky/manifest.json b/homeassistant/components/kulersky/manifest.json
index 49c4d4c1847e1f..a838c47c69835d 100644
--- a/homeassistant/components/kulersky/manifest.json
+++ b/homeassistant/components/kulersky/manifest.json
@@ -1,8 +1,14 @@
 {
   "domain": "kulersky",
   "name": "Kuler Sky",
+  "bluetooth": [
+    {
+      "service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c"
+    }
+  ],
   "codeowners": ["@emlove"],
   "config_flow": true,
+  "dependencies": ["bluetooth_adapters"],
   "documentation": "https://www.home-assistant.io/integrations/kulersky",
   "iot_class": "local_polling",
   "loggers": ["bleak", "pykulersky"],
diff --git a/homeassistant/components/kulersky/strings.json b/homeassistant/components/kulersky/strings.json
index ad8f0f41ae7b29..959d7d0690a983 100644
--- a/homeassistant/components/kulersky/strings.json
+++ b/homeassistant/components/kulersky/strings.json
@@ -1,13 +1,23 @@
 {
   "config": {
     "step": {
-      "confirm": {
-        "description": "[%key:common::config_flow::description::confirm_setup%]"
+      "user": {
+        "data": {
+          "address": "[%key:common::config_flow::data::device%]"
+        }
       }
     },
     "abort": {
-      "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
       "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
+    },
+    "error": {
+      "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+      "unknown": "[%key:common::config_flow::error::unknown%]"
+    }
+  },
+  "exceptions": {
+    "cannot_connect": {
+      "message": "[%key:common::config_flow::error::cannot_connect%]"
     }
   }
 }
diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py
index 1ff444ca25f07c..d150c28bf34791 100644
--- a/homeassistant/generated/bluetooth.py
+++ b/homeassistant/generated/bluetooth.py
@@ -389,6 +389,10 @@
         "domain": "keymitt_ble",
         "local_name": "mib*",
     },
+    {
+        "domain": "kulersky",
+        "service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c",
+    },
     {
         "domain": "lamarzocco",
         "local_name": "MICRA_*",
diff --git a/mypy.ini b/mypy.ini
index 685412e6e98e94..0e42a6c35945a1 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -2666,6 +2666,16 @@ disallow_untyped_defs = true
 warn_return_any = true
 warn_unreachable = true
 
+[mypy-homeassistant.components.kulersky.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
 [mypy-homeassistant.components.lacrosse.*]
 check_untyped_defs = true
 disallow_incomplete_defs = true
diff --git a/tests/components/kulersky/test_config_flow.py b/tests/components/kulersky/test_config_flow.py
index a2f3949bd07062..7615e94d2f0e4e 100644
--- a/tests/components/kulersky/test_config_flow.py
+++ b/tests/components/kulersky/test_config_flow.py
@@ -1,105 +1,182 @@
 """Test the Kuler Sky config flow."""
 
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, Mock, patch
 
 import pykulersky
 
-from homeassistant import config_entries
+from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
 from homeassistant.components.kulersky.config_flow import DOMAIN
+from homeassistant.config_entries import (
+    SOURCE_BLUETOOTH,
+    SOURCE_INTEGRATION_DISCOVERY,
+    SOURCE_USER,
+)
+from homeassistant.const import CONF_ADDRESS
 from homeassistant.core import HomeAssistant
 from homeassistant.data_entry_flow import FlowResultType
 
+from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
+
+KULERSKY_SERVICE_INFO = BluetoothServiceInfoBleak(
+    name="KulerLight",
+    manufacturer_data={},
+    service_data={},
+    service_uuids=["8d96a001-0002-64c2-0001-9acc4838521c"],
+    address="AA:BB:CC:DD:EE:FF",
+    rssi=-60,
+    source="local",
+    advertisement=generate_advertisement_data(
+        local_name="KulerLight",
+        manufacturer_data={},
+        service_data={},
+        service_uuids=["8d96a001-0002-64c2-0001-9acc4838521c"],
+    ),
+    device=generate_ble_device("AA:BB:CC:DD:EE:FF", "KulerLight"),
+    time=0,
+    connectable=True,
+    tx_power=-127,
+)
+
+
+async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
+    """Test discovery via bluetooth with a valid device."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_BLUETOOTH},
+        data=KULERSKY_SERVICE_INFO,
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "user"
+
+    with patch("pykulersky.Light", Mock(return_value=AsyncMock())):
+        result = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+    await hass.async_block_till_done()
+
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+    assert result["title"] == "KulerLight (EEFF)"
+    assert result["data"] == {
+        CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
+    }
 
-async def test_flow_success(hass: HomeAssistant) -> None:
-    """Test we get the form."""
 
+async def test_integration_discovery(hass: HomeAssistant) -> None:
+    """Test discovery via bluetooth with a valid device."""
+    with patch(
+        "homeassistant.components.kulersky.config_flow.async_last_service_info",
+        return_value=KULERSKY_SERVICE_INFO,
+    ):
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN,
+            context={"source": SOURCE_INTEGRATION_DISCOVERY},
+            data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+    assert result["title"] == "KulerLight (EEFF)"
+    assert result["data"] == {
+        CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
+    }
+
+
+async def test_integration_discovery_no_last_service_info(hass: HomeAssistant) -> None:
+    """Test discovery via bluetooth with a valid device."""
     result = await hass.config_entries.flow.async_init(
-        DOMAIN, context={"source": config_entries.SOURCE_USER}
+        DOMAIN,
+        context={"source": SOURCE_INTEGRATION_DISCOVERY},
+        data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
     )
-    assert result["type"] is FlowResultType.FORM
-    assert result["errors"] is None
-
-    light = MagicMock(spec=pykulersky.Light)
-    light.address = "AA:BB:CC:11:22:33"
-    light.name = "Bedroom"
-    with (
-        patch(
-            "homeassistant.components.kulersky.config_flow.pykulersky.discover",
-            return_value=[light],
-        ),
-        patch(
-            "homeassistant.components.kulersky.async_setup_entry",
-            return_value=True,
-        ) as mock_setup_entry,
+
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+    assert result["title"] == "AA:BB:CC:DD:EE:FF"
+    assert result["data"] == {
+        CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
+    }
+
+
+async def test_user_setup(hass: HomeAssistant) -> None:
+    """Test the user manually setting up the integration."""
+    with patch(
+        "homeassistant.components.kulersky.config_flow.async_discovered_service_info",
+        return_value=[
+            KULERSKY_SERVICE_INFO,
+            KULERSKY_SERVICE_INFO,
+        ],  # Pass twice to test duplicate logic
     ):
-        result2 = await hass.config_entries.flow.async_configure(
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN,
+            context={"source": SOURCE_USER},
+        )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "user"
+
+    with patch("pykulersky.Light", Mock(return_value=AsyncMock())):
+        result = await hass.config_entries.flow.async_configure(
             result["flow_id"],
-            {},
+            {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
         )
-        await hass.async_block_till_done()
+    await hass.async_block_till_done()
 
-    assert result2["type"] is FlowResultType.CREATE_ENTRY
-    assert result2["title"] == "Kuler Sky"
-    assert result2["data"] == {}
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+    assert result["title"] == "KulerLight (EEFF)"
+    assert result["data"] == {
+        CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
+    }
 
-    assert len(mock_setup_entry.mock_calls) == 1
 
+async def test_user_setup_no_devices(hass: HomeAssistant) -> None:
+    """Test the user manually setting up the integration."""
+    with patch(
+        "homeassistant.components.kulersky.config_flow.async_discovered_service_info",
+        return_value=[],
+    ):
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN,
+            context={"source": SOURCE_USER},
+        )
+    assert result["type"] is FlowResultType.ABORT
+    assert result["reason"] == "no_devices_found"
 
-async def test_flow_no_devices_found(hass: HomeAssistant) -> None:
-    """Test we get the form."""
 
+async def test_connection_error(hass: HomeAssistant) -> None:
+    """Test a connection error trying to set up."""
     result = await hass.config_entries.flow.async_init(
-        DOMAIN, context={"source": config_entries.SOURCE_USER}
+        DOMAIN,
+        context={"source": SOURCE_BLUETOOTH},
+        data=KULERSKY_SERVICE_INFO,
     )
     assert result["type"] is FlowResultType.FORM
-    assert result["errors"] is None
-
-    with (
-        patch(
-            "homeassistant.components.kulersky.config_flow.pykulersky.discover",
-            return_value=[],
-        ),
-        patch(
-            "homeassistant.components.kulersky.async_setup_entry",
-            return_value=True,
-        ) as mock_setup_entry,
-    ):
-        result2 = await hass.config_entries.flow.async_configure(
+    assert result["step_id"] == "user"
+
+    with patch("pykulersky.Light", Mock(side_effect=pykulersky.PykulerskyException)):
+        result = await hass.config_entries.flow.async_configure(
             result["flow_id"],
-            {},
+            {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
         )
-        await hass.async_block_till_done()
-
-    assert result2["type"] is FlowResultType.ABORT
-    assert result2["reason"] == "no_devices_found"
-    assert len(mock_setup_entry.mock_calls) == 0
+    await hass.async_block_till_done()
 
+    assert result["type"] is FlowResultType.FORM
+    assert result["errors"]["base"] == "cannot_connect"
 
-async def test_flow_exceptions_caught(hass: HomeAssistant) -> None:
-    """Test we get the form."""
 
+async def test_unexpected_error(hass: HomeAssistant) -> None:
+    """Test an unexpected error trying to set up."""
     result = await hass.config_entries.flow.async_init(
-        DOMAIN, context={"source": config_entries.SOURCE_USER}
+        DOMAIN,
+        context={"source": SOURCE_BLUETOOTH},
+        data=KULERSKY_SERVICE_INFO,
     )
     assert result["type"] is FlowResultType.FORM
-    assert result["errors"] is None
-
-    with (
-        patch(
-            "homeassistant.components.kulersky.config_flow.pykulersky.discover",
-            side_effect=pykulersky.PykulerskyException("TEST"),
-        ),
-        patch(
-            "homeassistant.components.kulersky.async_setup_entry",
-            return_value=True,
-        ) as mock_setup_entry,
-    ):
-        result2 = await hass.config_entries.flow.async_configure(
+    assert result["step_id"] == "user"
+
+    with patch("pykulersky.Light", Mock(side_effect=Exception)):
+        result = await hass.config_entries.flow.async_configure(
             result["flow_id"],
-            {},
+            {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
         )
-        await hass.async_block_till_done()
+    await hass.async_block_till_done()
 
-    assert result2["type"] is FlowResultType.ABORT
-    assert result2["reason"] == "no_devices_found"
-    assert len(mock_setup_entry.mock_calls) == 0
+    assert result["type"] is FlowResultType.FORM
+    assert result["errors"]["base"] == "unknown"
diff --git a/tests/components/kulersky/test_init.py b/tests/components/kulersky/test_init.py
new file mode 100644
index 00000000000000..54c5f146a6157d
--- /dev/null
+++ b/tests/components/kulersky/test_init.py
@@ -0,0 +1,65 @@
+"""Tests for init methods."""
+
+from homeassistant.components.kulersky.const import DOMAIN
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import CONF_ADDRESS
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+
+from tests.common import MockConfigEntry
+
+
+async def test_migrate_entry(
+    hass: HomeAssistant,
+) -> None:
+    """Test migrate config entry from v1 to v2."""
+
+    mock_config_entry_v1 = MockConfigEntry(
+        version=1,
+        domain=DOMAIN,
+        title="KulerSky",
+    )
+
+    mock_config_entry_v1.add_to_hass(hass)
+
+    dev_reg = dr.async_get(hass)
+    # Create device registry entries for old integration
+    dev_reg.async_get_or_create(
+        config_entry_id=mock_config_entry_v1.entry_id,
+        identifiers={(DOMAIN, "AA:BB:CC:11:22:33")},
+        name="KuLight 1",
+    )
+    dev_reg.async_get_or_create(
+        config_entry_id=mock_config_entry_v1.entry_id,
+        identifiers={(DOMAIN, "AA:BB:CC:44:55:66")},
+        name="KuLight 2",
+    )
+    await hass.config_entries.async_setup(mock_config_entry_v1.entry_id)
+    await hass.async_block_till_done()
+
+    assert mock_config_entry_v1.state is ConfigEntryState.SETUP_RETRY
+    assert mock_config_entry_v1.version == 2
+    assert mock_config_entry_v1.unique_id == "AA:BB:CC:11:22:33"
+    assert mock_config_entry_v1.data == {
+        CONF_ADDRESS: "AA:BB:CC:11:22:33",
+    }
+
+
+async def test_migrate_entry_no_devices_found(
+    hass: HomeAssistant,
+) -> None:
+    """Test migrate config entry from v1 to v2."""
+
+    mock_config_entry_v1 = MockConfigEntry(
+        version=1,
+        domain=DOMAIN,
+        title="KulerSky",
+    )
+
+    mock_config_entry_v1.add_to_hass(hass)
+
+    await hass.config_entries.async_setup(mock_config_entry_v1.entry_id)
+    await hass.async_block_till_done()
+
+    assert mock_config_entry_v1.state is ConfigEntryState.MIGRATION_ERROR
+    assert mock_config_entry_v1.version == 1
diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py
index 230a2562282157..bde60579af7d5c 100644
--- a/tests/components/kulersky/test_light.py
+++ b/tests/components/kulersky/test_light.py
@@ -1,16 +1,13 @@
 """Test the Kuler Sky lights."""
 
-from collections.abc import AsyncGenerator
-from unittest.mock import MagicMock, patch
+from collections.abc import Generator
+from unittest.mock import AsyncMock, MagicMock, patch
 
+from bleak.backends.device import BLEDevice
 import pykulersky
 import pytest
 
-from homeassistant.components.kulersky.const import (
-    DATA_ADDRESSES,
-    DATA_DISCOVERY_SUBSCRIPTION,
-    DOMAIN,
-)
+from homeassistant.components.kulersky.const import DOMAIN
 from homeassistant.components.light import (
     ATTR_BRIGHTNESS,
     ATTR_COLOR_MODE,
@@ -26,6 +23,7 @@
     ATTR_ENTITY_ID,
     ATTR_FRIENDLY_NAME,
     ATTR_SUPPORTED_FEATURES,
+    CONF_ADDRESS,
     STATE_OFF,
     STATE_ON,
     STATE_UNAVAILABLE,
@@ -37,26 +35,43 @@
 from tests.common import MockConfigEntry, async_fire_time_changed
 
 
+@pytest.fixture
+def mock_ble_device() -> Generator[MagicMock]:
+    """Mock BLEDevice."""
+    with patch(
+        "homeassistant.components.kulersky.async_ble_device_from_address",
+        return_value=BLEDevice(
+            address="AA:BB:CC:11:22:33", name="Bedroom", rssi=-50, details={}
+        ),
+    ) as ble_device:
+        yield ble_device
+
+
 @pytest.fixture
 async def mock_entry() -> MockConfigEntry:
     """Create a mock light entity."""
-    return MockConfigEntry(domain=DOMAIN)
+    return MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_ADDRESS: "AA:BB:CC:11:22:33"},
+        title="Bedroom",
+        version=2,
+    )
 
 
 @pytest.fixture
 async def mock_light(
-    hass: HomeAssistant, mock_entry: MockConfigEntry
-) -> AsyncGenerator[MagicMock]:
-    """Create a mock light entity."""
-
-    light = MagicMock(spec=pykulersky.Light)
+    hass: HomeAssistant, mock_entry: MockConfigEntry, mock_ble_device: MagicMock
+) -> Generator[AsyncMock]:
+    """Mock pykulersky light."""
+    light = AsyncMock()
     light.address = "AA:BB:CC:11:22:33"
     light.name = "Bedroom"
     light.connect.return_value = True
     light.get_color.return_value = (0, 0, 0, 0)
+
     with patch(
-        "homeassistant.components.kulersky.light.pykulersky.discover",
-        return_value=[light],
+        "pykulersky.Light",
+        return_value=light,
     ):
         mock_entry.add_to_hass(hass)
         await hass.config_entries.async_setup(mock_entry.entry_id)
@@ -67,7 +82,7 @@ async def mock_light(
         yield light
 
 
-async def test_init(hass: HomeAssistant, mock_light: MagicMock) -> None:
+async def test_init(hass: HomeAssistant, mock_light: AsyncMock) -> None:
     """Test platform setup."""
     state = hass.states.get("light.bedroom")
     assert state.state == STATE_OFF
@@ -83,24 +98,14 @@ async def test_init(hass: HomeAssistant, mock_light: MagicMock) -> None:
         ATTR_RGBW_COLOR: None,
     }
 
-    with patch.object(hass.loop, "stop"):
-        await hass.async_stop()
-        await hass.async_block_till_done()
-
-    assert mock_light.disconnect.called
-
 
 async def test_remove_entry(
     hass: HomeAssistant, mock_light: MagicMock, mock_entry: MockConfigEntry
 ) -> None:
     """Test platform setup."""
-    assert hass.data[DOMAIN][DATA_ADDRESSES] == {"AA:BB:CC:11:22:33"}
-    assert DATA_DISCOVERY_SUBSCRIPTION in hass.data[DOMAIN]
-
     await hass.config_entries.async_remove(mock_entry.entry_id)
 
     assert mock_light.disconnect.called
-    assert DOMAIN not in hass.data
 
 
 async def test_remove_entry_exceptions_caught(


Reviews:
user: login: balloob
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T01:06:08Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T01:42:00Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T01:42:18Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T01:43:36Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T01:45:48Z
user: login: emlove
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T01:54:16Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T02:00:31Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T03:51:34Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T03:56:00Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T03:56:12Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T03:56:53Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T03:57:22Z
user: login: bdraco
body: Thanks @emlove 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-09T07:03:31Z

Review comments:
diff_hunk: @@ -1,33 +1,124 @@
 """Kuler Sky lights integration."""
 
+import logging
+from types import MappingProxyType
+
+from homeassistant.components.bluetooth import (
+    DOMAIN as BLUETOOTH_DOMAIN,
+    async_ble_device_from_address,
+)
 from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
+from homeassistant.const import CONF_ADDRESS, Platform
 from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.discovery_flow import DiscoveryKey
 
-from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
+from .const import DOMAIN
 
 PLATFORMS = [Platform.LIGHT]
 
+_LOGGER = logging.getLogger(__name__)
+
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Set up Kuler Sky from a config entry."""
-    if DOMAIN not in hass.data:
-        hass.data[DOMAIN] = {}
-    if DATA_ADDRESSES not in hass.data[DOMAIN]:
-        hass.data[DOMAIN][DATA_ADDRESSES] = set()
-
+    ble_device = async_ble_device_from_address(
+        hass, entry.data[CONF_ADDRESS], connectable=True
+    )
+    if not ble_device:
+        raise ConfigEntryNotReady(
+            translation_domain=DOMAIN,
+            translation_key="cannot_connect",
+        )
     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 
     return True
 
 
 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Unload a config entry."""
-    # Stop discovery
-    unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None)
-    if unregister_discovery:
-        unregister_discovery()
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 
-    hass.data.pop(DOMAIN, None)
 
-    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+    """Migrate old entry."""
+    _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+    # Version 1 was a single entry instance that started a bluetooth discovery
+    # thread to add devices. Version 2 has one config entry per device, and
+    # supports core bluetooth discovery
+    if config_entry.version == 1:
+        dev_reg = dr.async_get(hass)
+        entity_reg = er.async_get(hass)
+        devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
+
+        if len(devices) == 0:
+            _LOGGER.error("Unable to migrate; No devices registered")
+            return False
+
+        first_device = devices[0]
+        address = next(iter(first_device.identifiers))[1]
path: homeassistant/components/kulersky/__init__.py
user: login: balloob
body: just to be sure, you want to find the one where index 0 equals `DOMAIN`
created_at: 2025-04-05T01:06:08Z
author_association: MEMBER
diff_hunk: @@ -1,26 +1,116 @@
 """Config flow for Kuler Sky."""
 
 import logging
+from typing import Any
 
+from bluetooth_data_tools import human_readable_name
 import pykulersky
+import voluptuous as vol
 
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
+from homeassistant.components.bluetooth import (
+    BluetoothServiceInfoBleak,
+    async_discovered_service_info,
+)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ADDRESS
 
-from .const import DOMAIN
+from .const import DOMAIN, EXPECTED_SERVICE_UUID
 
 _LOGGER = logging.getLogger(__name__)
 
 
-async def _async_has_devices(hass: HomeAssistant) -> bool:
-    """Return if there are devices that can be discovered."""
-    # Check if there are any devices that can be discovered in the network.
-    try:
-        devices = await pykulersky.discover()
-    except pykulersky.PykulerskyException as exc:
-        _LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc)
-        return False
-    return len(devices) > 0
+class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Kulersky."""
 
+    VERSION = 2
 
-config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices)
+    def __init__(self) -> None:
+        """Initialize the config flow."""
+        self._discovery_info: BluetoothServiceInfoBleak | None = None
+        self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
+
+    async def async_step_bluetooth(
+        self, discovery_info: BluetoothServiceInfoBleak
+    ) -> ConfigFlowResult:
+        """Handle the bluetooth discovery step."""
+        await self.async_set_unique_id(discovery_info.address)
+        self._abort_if_unique_id_configured()
+        self._discovery_info = discovery_info
+        self.context["title_placeholders"] = {
+            "name": human_readable_name(
+                None, discovery_info.name, discovery_info.address
+            )
+        }
+        return await self.async_step_user()
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the user step to pick discovered device."""
+        errors: dict[str, str] = {}
+
+        if user_input is not None:
+            address = user_input[CONF_ADDRESS]
+            discovery_info = self._discovered_devices[address]
+            local_name = human_readable_name(
+                None, discovery_info.name, discovery_info.address
+            )
+            await self.async_set_unique_id(
+                discovery_info.address, raise_on_progress=False
+            )
+            self._abort_if_unique_id_configured()
+            try:
+                kulersky_light = pykulersky.Light(discovery_info.address)
+                await kulersky_light.connect()
+            except pykulersky.PykulerskyException:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected error")
+                errors["base"] = "unknown"
+            else:
path: homeassistant/components/kulersky/config_flow.py
user: login: bdraco
body: should disconnect be in a finally block here?
created_at: 2025-04-05T01:42:00Z
author_association: MEMBER
diff_hunk: @@ -1,26 +1,116 @@
 """Config flow for Kuler Sky."""
 
 import logging
+from typing import Any
 
+from bluetooth_data_tools import human_readable_name
 import pykulersky
+import voluptuous as vol
 
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
+from homeassistant.components.bluetooth import (
+    BluetoothServiceInfoBleak,
+    async_discovered_service_info,
+)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ADDRESS
 
-from .const import DOMAIN
+from .const import DOMAIN, EXPECTED_SERVICE_UUID
 
 _LOGGER = logging.getLogger(__name__)
 
 
-async def _async_has_devices(hass: HomeAssistant) -> bool:
-    """Return if there are devices that can be discovered."""
-    # Check if there are any devices that can be discovered in the network.
-    try:
-        devices = await pykulersky.discover()
-    except pykulersky.PykulerskyException as exc:
-        _LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc)
-        return False
-    return len(devices) > 0
+class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Kulersky."""
 
+    VERSION = 2
 
-config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices)
+    def __init__(self) -> None:
+        """Initialize the config flow."""
+        self._discovery_info: BluetoothServiceInfoBleak | None = None
+        self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
+
+    async def async_step_bluetooth(
+        self, discovery_info: BluetoothServiceInfoBleak
+    ) -> ConfigFlowResult:
+        """Handle the bluetooth discovery step."""
+        await self.async_set_unique_id(discovery_info.address)
+        self._abort_if_unique_id_configured()
+        self._discovery_info = discovery_info
+        self.context["title_placeholders"] = {
+            "name": human_readable_name(
+                None, discovery_info.name, discovery_info.address
+            )
+        }
+        return await self.async_step_user()
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the user step to pick discovered device."""
+        errors: dict[str, str] = {}
+
+        if user_input is not None:
+            address = user_input[CONF_ADDRESS]
+            discovery_info = self._discovered_devices[address]
+            local_name = human_readable_name(
+                None, discovery_info.name, discovery_info.address
+            )
+            await self.async_set_unique_id(
+                discovery_info.address, raise_on_progress=False
+            )
+            self._abort_if_unique_id_configured()
+            try:
+                kulersky_light = pykulersky.Light(discovery_info.address)
+                await kulersky_light.connect()
+            except pykulersky.PykulerskyException:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected error")
+                errors["base"] = "unknown"
+            else:
+                await kulersky_light.disconnect()
+                return self.async_create_entry(
+                    title=local_name,
+                    data={
+                        CONF_ADDRESS: discovery_info.address,
+                    },
+                )
+
+        if discovery := self._discovery_info:
+            self._discovered_devices[discovery.address] = discovery
+        else:
+            current_addresses = self._async_current_ids()
+            for discovery in async_discovered_service_info(self.hass):
+                if (
+                    discovery.address in current_addresses
+                    or discovery.address in self._discovered_devices
+                    or EXPECTED_SERVICE_UUID not in discovery.service_uuids
+                ):
+                    continue
path: homeassistant/components/kulersky/config_flow.py
user: login: bdraco
body: missing coverage for this line
created_at: 2025-04-05T01:42:17Z
author_association: MEMBER
diff_hunk: @@ -1,33 +1,126 @@
 """Kuler Sky lights integration."""
 
+import logging
+from types import MappingProxyType
+
+from homeassistant.components.bluetooth import (
+    DOMAIN as BLUETOOTH_DOMAIN,
+    async_ble_device_from_address,
+)
 from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
+from homeassistant.const import CONF_ADDRESS, Platform
 from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.discovery_flow import DiscoveryKey
 
-from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
+from .const import DOMAIN
 
 PLATFORMS = [Platform.LIGHT]
 
+_LOGGER = logging.getLogger(__name__)
+
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Set up Kuler Sky from a config entry."""
-    if DOMAIN not in hass.data:
-        hass.data[DOMAIN] = {}
-    if DATA_ADDRESSES not in hass.data[DOMAIN]:
-        hass.data[DOMAIN][DATA_ADDRESSES] = set()
-
+    ble_device = async_ble_device_from_address(
+        hass, entry.data[CONF_ADDRESS], connectable=True
+    )
+    if not ble_device:
+        raise ConfigEntryNotReady(
+            translation_domain=DOMAIN,
+            translation_key="cannot_connect",
+        )
     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 
     return True
 
 
 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Unload a config entry."""
-    # Stop discovery
-    unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None)
-    if unregister_discovery:
-        unregister_discovery()
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 
-    hass.data.pop(DOMAIN, None)
 
-    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+    """Migrate old entry."""
+    _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+    # Version 1 was a single entry instance that started a bluetooth discovery
+    # thread to add devices. Version 2 has one config entry per device, and
+    # supports core bluetooth discovery
+    if config_entry.version == 1:
+        dev_reg = dr.async_get(hass)
+        entity_reg = er.async_get(hass)
+        devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
+
+        if len(devices) == 0:
+            _LOGGER.error("Unable to migrate; No devices registered")
+            return False
+
+        first_device = devices[0]
+        domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN]
+        address = next(iter(domain_identifiers))[1]
+        hass.config_entries.async_update_entry(
+            config_entry,
+            title=first_device.name or address,
+            data={CONF_ADDRESS: address},
+            discovery_keys=MappingProxyType(
+                {
+                    BLUETOOTH_DOMAIN: (
+                        DiscoveryKey(
+                            domain=BLUETOOTH_DOMAIN,
+                            key=address,
+                            version=1,
+                        ),
+                    )
+                }
+            ),
+            unique_id=address,
+            version=2,
+        )
+
+        # Create new config entries for the remaining devices
path: homeassistant/components/kulersky/__init__.py
user: login: bdraco
body: We usually don't create `ConfigEntry` directly and let config flows do this.

Maybe make these import flows to create the new entries instead
created_at: 2025-04-05T01:43:36Z
author_association: MEMBER
diff_hunk: @@ -71,37 +51,29 @@ class KulerskyLight(LightEntity):
     _attr_supported_color_modes = {ColorMode.RGBW}
     _attr_color_mode = ColorMode.RGBW
 
-    def __init__(self, light: pykulersky.Light) -> None:
+    def __init__(self, name: str, address: str, light: pykulersky.Light) -> None:
         """Initialize a Kuler Sky light."""
         self._light = light
-        self._attr_unique_id = light.address
+        self._attr_unique_id = address
         self._attr_device_info = DeviceInfo(
-            identifiers={(DOMAIN, light.address)},
+            identifiers={(DOMAIN, address)},
path: homeassistant/components/kulersky/light.py
user: login: bdraco
body: Probably could use `connections={(CONNECTION_BLUETOOTH, address)},` so users can see it in the Bluetooth adv monitor


created_at: 2025-04-05T01:45:48Z
author_association: MEMBER
diff_hunk: @@ -1,33 +1,126 @@
 """Kuler Sky lights integration."""
 
+import logging
+from types import MappingProxyType
+
+from homeassistant.components.bluetooth import (
+    DOMAIN as BLUETOOTH_DOMAIN,
+    async_ble_device_from_address,
+)
 from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
+from homeassistant.const import CONF_ADDRESS, Platform
 from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.discovery_flow import DiscoveryKey
 
-from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
+from .const import DOMAIN
 
 PLATFORMS = [Platform.LIGHT]
 
+_LOGGER = logging.getLogger(__name__)
+
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Set up Kuler Sky from a config entry."""
-    if DOMAIN not in hass.data:
-        hass.data[DOMAIN] = {}
-    if DATA_ADDRESSES not in hass.data[DOMAIN]:
-        hass.data[DOMAIN][DATA_ADDRESSES] = set()
-
+    ble_device = async_ble_device_from_address(
+        hass, entry.data[CONF_ADDRESS], connectable=True
+    )
+    if not ble_device:
+        raise ConfigEntryNotReady(
+            translation_domain=DOMAIN,
+            translation_key="cannot_connect",
+        )
     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 
     return True
 
 
 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Unload a config entry."""
-    # Stop discovery
-    unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None)
-    if unregister_discovery:
-        unregister_discovery()
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 
-    hass.data.pop(DOMAIN, None)
 
-    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+    """Migrate old entry."""
+    _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+    # Version 1 was a single entry instance that started a bluetooth discovery
+    # thread to add devices. Version 2 has one config entry per device, and
+    # supports core bluetooth discovery
+    if config_entry.version == 1:
+        dev_reg = dr.async_get(hass)
+        entity_reg = er.async_get(hass)
+        devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
+
+        if len(devices) == 0:
+            _LOGGER.error("Unable to migrate; No devices registered")
+            return False
+
+        first_device = devices[0]
+        domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN]
+        address = next(iter(domain_identifiers))[1]
+        hass.config_entries.async_update_entry(
+            config_entry,
+            title=first_device.name or address,
+            data={CONF_ADDRESS: address},
+            discovery_keys=MappingProxyType(
+                {
+                    BLUETOOTH_DOMAIN: (
+                        DiscoveryKey(
+                            domain=BLUETOOTH_DOMAIN,
+                            key=address,
+                            version=1,
+                        ),
+                    )
+                }
+            ),
+            unique_id=address,
+            version=2,
+        )
+
+        # Create new config entries for the remaining devices
path: homeassistant/components/kulersky/__init__.py
user: login: emlove
body: I think if we want to keep user involved then we could just simply this migration by dropping everything below the comment. Any remaining devices will get picked up by default bluetooth discovery.
created_at: 2025-04-05T01:54:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,33 +1,126 @@
 """Kuler Sky lights integration."""
 
+import logging
+from types import MappingProxyType
+
+from homeassistant.components.bluetooth import (
+    DOMAIN as BLUETOOTH_DOMAIN,
+    async_ble_device_from_address,
+)
 from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
+from homeassistant.const import CONF_ADDRESS, Platform
 from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.discovery_flow import DiscoveryKey
 
-from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
+from .const import DOMAIN
 
 PLATFORMS = [Platform.LIGHT]
 
+_LOGGER = logging.getLogger(__name__)
+
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Set up Kuler Sky from a config entry."""
-    if DOMAIN not in hass.data:
-        hass.data[DOMAIN] = {}
-    if DATA_ADDRESSES not in hass.data[DOMAIN]:
-        hass.data[DOMAIN][DATA_ADDRESSES] = set()
-
+    ble_device = async_ble_device_from_address(
+        hass, entry.data[CONF_ADDRESS], connectable=True
+    )
+    if not ble_device:
+        raise ConfigEntryNotReady(
+            translation_domain=DOMAIN,
+            translation_key="cannot_connect",
+        )
     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 
     return True
 
 
 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Unload a config entry."""
-    # Stop discovery
-    unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None)
-    if unregister_discovery:
-        unregister_discovery()
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 
-    hass.data.pop(DOMAIN, None)
 
-    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+    """Migrate old entry."""
+    _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+    # Version 1 was a single entry instance that started a bluetooth discovery
+    # thread to add devices. Version 2 has one config entry per device, and
+    # supports core bluetooth discovery
+    if config_entry.version == 1:
+        dev_reg = dr.async_get(hass)
+        entity_reg = er.async_get(hass)
+        devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
+
+        if len(devices) == 0:
+            _LOGGER.error("Unable to migrate; No devices registered")
+            return False
+
+        first_device = devices[0]
+        domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN]
+        address = next(iter(domain_identifiers))[1]
+        hass.config_entries.async_update_entry(
+            config_entry,
+            title=first_device.name or address,
+            data={CONF_ADDRESS: address},
+            discovery_keys=MappingProxyType(
+                {
+                    BLUETOOTH_DOMAIN: (
+                        DiscoveryKey(
+                            domain=BLUETOOTH_DOMAIN,
+                            key=address,
+                            version=1,
+                        ),
+                    )
+                }
+            ),
+            unique_id=address,
+            version=2,
+        )
+
+        # Create new config entries for the remaining devices
path: homeassistant/components/kulersky/__init__.py
user: login: bdraco
body: I think its fine to add them but we should avoid creating `ConfigEntry` objects in integrations and let the config flow tooling take care of all that.

```
			hass.async_create_task(
                hass.config_entries.flow.async_init(
                    DOMAIN,
                    context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
                    data=`....data needed to import it....`,
                ),
            )
```

.. and implement `async_step_integration_discovery` in the config flow.


created_at: 2025-04-05T02:00:31Z
author_association: MEMBER
diff_hunk: @@ -1,33 +1,96 @@
 """Kuler Sky lights integration."""
 
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
+import logging
+from types import MappingProxyType
+
+from homeassistant.components.bluetooth import (
+    DOMAIN as BLUETOOTH_DOMAIN,
+    async_ble_device_from_address,
+)
+from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
+from homeassistant.const import CONF_ADDRESS, Platform
 from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.discovery_flow import DiscoveryKey
 
-from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
+from .const import DOMAIN
 
 PLATFORMS = [Platform.LIGHT]
 
+_LOGGER = logging.getLogger(__name__)
+
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Set up Kuler Sky from a config entry."""
-    if DOMAIN not in hass.data:
-        hass.data[DOMAIN] = {}
-    if DATA_ADDRESSES not in hass.data[DOMAIN]:
-        hass.data[DOMAIN][DATA_ADDRESSES] = set()
-
+    ble_device = async_ble_device_from_address(
+        hass, entry.data[CONF_ADDRESS], connectable=True
+    )
+    if not ble_device:
+        raise ConfigEntryNotReady(
+            translation_domain=DOMAIN,
+            translation_key="cannot_connect",
+        )
     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 
     return True
 
 
 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Unload a config entry."""
-    # Stop discovery
-    unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None)
-    if unregister_discovery:
-        unregister_discovery()
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 
-    hass.data.pop(DOMAIN, None)
 
-    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+    """Migrate old entry."""
+    _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+    # Version 1 was a single entry instance that started a bluetooth discovery
+    # thread to add devices. Version 2 has one config entry per device, and
+    # supports core bluetooth discovery
+    if config_entry.version == 1:
+        dev_reg = dr.async_get(hass)
+        devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
+
+        if len(devices) == 0:
+            _LOGGER.error("Unable to migrate; No devices registered")
+            return False
+
+        first_device = devices[0]
+        domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN]
+        address = next(iter(domain_identifiers))[1]
+        hass.config_entries.async_update_entry(
+            config_entry,
+            title=first_device.name or address,
+            data={CONF_ADDRESS: address},
+            discovery_keys=MappingProxyType(
+                {
+                    BLUETOOTH_DOMAIN: (
+                        DiscoveryKey(
+                            domain=BLUETOOTH_DOMAIN,
+                            key=address,
+                            version=1,
+                        ),
+                    )
+                }
+            ),
path: homeassistant/components/kulersky/__init__.py
user: login: bdraco
body: ```suggestion
```

This will get populated automatically the next time its discovered. Let's leave it out since otherwise we have to maintain it when the version of the DiscoveryKey changes
created_at: 2025-04-08T03:51:34Z
author_association: MEMBER
diff_hunk: @@ -1,26 +1,120 @@
 """Config flow for Kuler Sky."""
 
 import logging
+from typing import Any
 
+from bluetooth_data_tools import human_readable_name
 import pykulersky
+import voluptuous as vol
 
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
+from homeassistant.components.bluetooth import (
+    BluetoothServiceInfoBleak,
+    async_discovered_service_info,
+)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ADDRESS
 
-from .const import DOMAIN
+from .const import DOMAIN, EXPECTED_SERVICE_UUID
 
 _LOGGER = logging.getLogger(__name__)
 
 
-async def _async_has_devices(hass: HomeAssistant) -> bool:
-    """Return if there are devices that can be discovered."""
-    # Check if there are any devices that can be discovered in the network.
-    try:
-        devices = await pykulersky.discover()
-    except pykulersky.PykulerskyException as exc:
-        _LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc)
-        return False
-    return len(devices) > 0
+class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Kulersky."""
 
+    VERSION = 2
 
-config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices)
+    def __init__(self) -> None:
+        """Initialize the config flow."""
+        self._discovery_info: BluetoothServiceInfoBleak | None = None
+        self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
+
path: homeassistant/components/kulersky/config_flow.py
user: login: bdraco
body: ```suggestion

    async def async_step_integration_discovery(
        self, discovery_info: dict[str, str]
    ) -> ConfigFlowResult:
        """Handle the integration discovery step.

        The old version of the integration used to have multiple
        device in a single config entry. This is now deprecated.
        The integration discovery step is used to create config
        entries for each device beyond the first one.
        """
        address: str = discovery_info[CONF_ADDRESS]
        if service_info := async_last_service_info(self.hass, address):
            title = human_readable_name(None, service_info.name, service_info.address)
        else:
            title = address
        await self.async_set_unique_id(address)
        self._abort_if_unique_id_configured()
        return self.async_create_entry(
            title=title,
            data={CONF_ADDRESS: address},
        )

```
created_at: 2025-04-08T03:56:00Z
author_association: MEMBER
diff_hunk: @@ -1,26 +1,120 @@
 """Config flow for Kuler Sky."""
 
 import logging
+from typing import Any
 
+from bluetooth_data_tools import human_readable_name
 import pykulersky
+import voluptuous as vol
 
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
+from homeassistant.components.bluetooth import (
+    BluetoothServiceInfoBleak,
+    async_discovered_service_info,
path: homeassistant/components/kulersky/config_flow.py
user: login: bdraco
body: ```suggestion
    async_discovered_service_info,
    async_last_service_info,
```
created_at: 2025-04-08T03:56:11Z
author_association: MEMBER
diff_hunk: @@ -1,26 +1,120 @@
 """Config flow for Kuler Sky."""
 
 import logging
+from typing import Any
 
+from bluetooth_data_tools import human_readable_name
 import pykulersky
+import voluptuous as vol
 
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
+from homeassistant.components.bluetooth import (
+    BluetoothServiceInfoBleak,
+    async_discovered_service_info,
+)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ADDRESS
 
-from .const import DOMAIN
+from .const import DOMAIN, EXPECTED_SERVICE_UUID
 
 _LOGGER = logging.getLogger(__name__)
 
 
-async def _async_has_devices(hass: HomeAssistant) -> bool:
-    """Return if there are devices that can be discovered."""
-    # Check if there are any devices that can be discovered in the network.
-    try:
-        devices = await pykulersky.discover()
-    except pykulersky.PykulerskyException as exc:
-        _LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc)
-        return False
-    return len(devices) > 0
+class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Kulersky."""
 
+    VERSION = 2
 
-config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices)
+    def __init__(self) -> None:
+        """Initialize the config flow."""
+        self._discovery_info: BluetoothServiceInfoBleak | None = None
+        self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
+
path: homeassistant/components/kulersky/config_flow.py
user: login: bdraco
body: This will need a test as well
created_at: 2025-04-08T03:56:53Z
author_association: MEMBER
diff_hunk: @@ -1,33 +1,96 @@
 """Kuler Sky lights integration."""
 
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
+import logging
+from types import MappingProxyType
+
+from homeassistant.components.bluetooth import (
+    DOMAIN as BLUETOOTH_DOMAIN,
+    async_ble_device_from_address,
+)
+from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
+from homeassistant.const import CONF_ADDRESS, Platform
 from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.discovery_flow import DiscoveryKey
 
-from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
+from .const import DOMAIN
 
 PLATFORMS = [Platform.LIGHT]
 
+_LOGGER = logging.getLogger(__name__)
+
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Set up Kuler Sky from a config entry."""
-    if DOMAIN not in hass.data:
-        hass.data[DOMAIN] = {}
-    if DATA_ADDRESSES not in hass.data[DOMAIN]:
-        hass.data[DOMAIN][DATA_ADDRESSES] = set()
-
+    ble_device = async_ble_device_from_address(
+        hass, entry.data[CONF_ADDRESS], connectable=True
+    )
+    if not ble_device:
+        raise ConfigEntryNotReady(
+            translation_domain=DOMAIN,
+            translation_key="cannot_connect",
+        )
     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 
     return True
 
 
 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Unload a config entry."""
-    # Stop discovery
-    unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None)
-    if unregister_discovery:
-        unregister_discovery()
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 
-    hass.data.pop(DOMAIN, None)
 
-    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+    """Migrate old entry."""
+    _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+    # Version 1 was a single entry instance that started a bluetooth discovery
+    # thread to add devices. Version 2 has one config entry per device, and
+    # supports core bluetooth discovery
+    if config_entry.version == 1:
+        dev_reg = dr.async_get(hass)
+        devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
+
+        if len(devices) == 0:
+            _LOGGER.error("Unable to migrate; No devices registered")
+            return False
+
+        first_device = devices[0]
+        domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN]
+        address = next(iter(domain_identifiers))[1]
+        hass.config_entries.async_update_entry(
+            config_entry,
+            title=first_device.name or address,
+            data={CONF_ADDRESS: address},
+            discovery_keys=MappingProxyType(
+                {
+                    BLUETOOTH_DOMAIN: (
+                        DiscoveryKey(
+                            domain=BLUETOOTH_DOMAIN,
+                            key=address,
+                            version=1,
+                        ),
+                    )
+                }
+            ),
+            unique_id=address,
+            version=2,
+        )
+
+        # Create new config flows for the remaining devices
+        for device in devices[1:]:
+            domain_identifiers = [i for i in device.identifiers if i[0] == DOMAIN]
+            address = next(iter(domain_identifiers))[1]
+
+            hass.async_create_task(
+                hass.config_entries.flow.async_init(
+                    DOMAIN,
+                    context={"source": SOURCE_INTEGRATION_DISCOVERY},
path: homeassistant/components/kulersky/__init__.py
user: login: bdraco
body: We need to implement `async_step_integration_discovery` as well

https://github.com/home-assistant/core/pull/142309/files#r2032336370
created_at: 2025-04-08T03:57:22Z
author_association: MEMBER

PR comments:
user: login: balloob
created_at: 2025-04-05T01:00:16Z
body: Hi! Welcome back 🎉 

Looks like the link to the changelog is pointing at the wrong location. 
author_association: MEMBER
user: login: emlove
created_at: 2025-04-05T01:03:08Z
body: Whoops! Just fixed that.
author_association: CONTRIBUTOR
user: login: bdraco
created_at: 2025-04-06T10:41:33Z
body: Feel free to reach out on discord (bdraco) when you need a followup review.

If your handle is different than here, please post your handle or drop me an email with it so I know to accept messages.
author_association: MEMBER